From 83bdeb81f08602288d048a5f1a624650007965e3 Mon Sep 17 00:00:00 2001 From: yhh <359807859@qq.com> Date: Fri, 6 Nov 2026 04:44:41 -0800 Subject: [PATCH] feat(studio): the workbench gets a terminal of your own, as the Terminal board draws it A fourth kind of tab opens a real shell (ConPTY on Windows, a pty elsewhere) in the project folder, outside the model's permissions and sandbox and with the host's provider keys left out of its environment. The kernel keeps each shell's output, so a tab left or a window reloaded comes back as it was; a tab is named for the command submitted while the shell is busy and carries a blue dot, and closing a busy one asks first. Also: the composer's note stack no longer clips to a few pixels when it holds two notes, which left the second one overlapping the input. --- desktop/frontend-next/package.json | 7 +- desktop/frontend-next/pnpm-lock.yaml | 24 ++ .../frontend-next/src/actions_workbench.ts | 18 + desktop/frontend-next/src/design/composer.css | 3 + desktop/frontend-next/src/design/design.css | 1 + desktop/frontend-next/src/design/terminal.css | 44 +++ desktop/frontend-next/src/i18n/kernel.ts | 5 + desktop/frontend-next/src/i18n/msg/index.ts | 3 +- desktop/frontend-next/src/i18n/msg/kernel.ts | 5 + .../frontend-next/src/i18n/msg/terminal.ts | 28 ++ .../frontend-next/src/port/mock_terminal.ts | 115 ++++++ .../frontend-next/src/port/mock_workspace.ts | 4 +- desktop/frontend-next/src/port/port.ts | 3 +- desktop/frontend-next/src/port/sse.ts | 4 +- .../frontend-next/src/port/sse_terminal.ts | 43 +++ desktop/frontend-next/src/port/terminal.ts | 50 +++ desktop/frontend-next/src/ui/App.tsx | 11 +- desktop/frontend-next/src/ui/KeysSheet.tsx | 1 + .../frontend-next/src/ui/ManualBrowsers.tsx | 8 + desktop/frontend-next/src/ui/Pane.tsx | 5 +- desktop/frontend-next/src/ui/StudioIcon.tsx | 2 + .../frontend-next/src/ui/TerminalCloseAsk.tsx | 20 + desktop/frontend-next/src/ui/TerminalHead.tsx | 83 +++++ .../frontend-next/src/ui/TerminalLayer.tsx | 33 ++ .../src/ui/TerminalView.test.tsx | 167 +++++++++ desktop/frontend-next/src/ui/TerminalView.tsx | 247 +++++++++++++ .../src/ui/WorkbenchLauncher.tsx | 8 +- .../frontend-next/src/ui/WorkbenchPanel.tsx | 35 +- .../frontend-next/src/ui/WorkbenchTabIcon.tsx | 12 + .../src/ui/WorkbenchTerminals.tsx | 35 ++ desktop/frontend-next/src/ui/panetypes.ts | 1 + .../frontend-next/src/ui/terminal_prefs.ts | 37 ++ .../frontend-next/src/ui/terminal_theme.ts | 28 ++ desktop/frontend-next/src/ui/useTabClose.ts | 1 + .../frontend-next/src/ui/useTerminalTabs.ts | 113 ++++++ .../workbench-terminal.interaction.test.tsx | 108 ++++++ .../frontend-next/src/ui/workbench_tabs.ts | 5 +- go.mod | 2 + go.sum | 5 + internal/frontend/serve/auth.go | 16 + internal/frontend/serve/host_routes.go | 8 + internal/frontend/serve/hub_close.go | 1 + internal/frontend/serve/routes.go | 2 +- internal/frontend/serve/serve.go | 33 +- internal/frontend/serve/terminals.go | 226 ++++++++++++ internal/frontend/serve/terminals_test.go | 155 ++++++++ internal/platform/terminal/busy_windows.go | 74 ++++ .../platform/terminal/busy_windows_test.go | 23 ++ internal/platform/terminal/doc.go | 13 + internal/platform/terminal/env.go | 19 + internal/platform/terminal/errors.go | 36 ++ internal/platform/terminal/pty.go | 14 + internal/platform/terminal/pty_unix.go | 72 ++++ internal/platform/terminal/pty_windows.go | 55 +++ internal/platform/terminal/ring.go | 45 +++ internal/platform/terminal/shells.go | 75 ++++ internal/platform/terminal/terminal.go | 349 ++++++++++++++++++ internal/platform/terminal/terminal_test.go | 265 +++++++++++++ tools/repolint/layers.go | 1 + tools/repolint/wireparity.go | 4 + 60 files changed, 2755 insertions(+), 55 deletions(-) create mode 100644 desktop/frontend-next/src/design/terminal.css create mode 100644 desktop/frontend-next/src/i18n/msg/terminal.ts create mode 100644 desktop/frontend-next/src/port/mock_terminal.ts create mode 100644 desktop/frontend-next/src/port/sse_terminal.ts create mode 100644 desktop/frontend-next/src/port/terminal.ts create mode 100644 desktop/frontend-next/src/ui/ManualBrowsers.tsx create mode 100644 desktop/frontend-next/src/ui/TerminalCloseAsk.tsx create mode 100644 desktop/frontend-next/src/ui/TerminalHead.tsx create mode 100644 desktop/frontend-next/src/ui/TerminalLayer.tsx create mode 100644 desktop/frontend-next/src/ui/TerminalView.test.tsx create mode 100644 desktop/frontend-next/src/ui/TerminalView.tsx create mode 100644 desktop/frontend-next/src/ui/WorkbenchTabIcon.tsx create mode 100644 desktop/frontend-next/src/ui/WorkbenchTerminals.tsx create mode 100644 desktop/frontend-next/src/ui/terminal_prefs.ts create mode 100644 desktop/frontend-next/src/ui/terminal_theme.ts create mode 100644 desktop/frontend-next/src/ui/useTerminalTabs.ts create mode 100644 desktop/frontend-next/src/ui/workbench-terminal.interaction.test.tsx create mode 100644 internal/frontend/serve/terminals.go create mode 100644 internal/frontend/serve/terminals_test.go create mode 100644 internal/platform/terminal/busy_windows.go create mode 100644 internal/platform/terminal/busy_windows_test.go create mode 100644 internal/platform/terminal/doc.go create mode 100644 internal/platform/terminal/env.go create mode 100644 internal/platform/terminal/errors.go create mode 100644 internal/platform/terminal/pty.go create mode 100644 internal/platform/terminal/pty_unix.go create mode 100644 internal/platform/terminal/pty_windows.go create mode 100644 internal/platform/terminal/ring.go create mode 100644 internal/platform/terminal/shells.go create mode 100644 internal/platform/terminal/terminal.go create mode 100644 internal/platform/terminal/terminal_test.go diff --git a/desktop/frontend-next/package.json b/desktop/frontend-next/package.json index f3322fad13..7a5f497ac0 100644 --- a/desktop/frontend-next/package.json +++ b/desktop/frontend-next/package.json @@ -36,6 +36,9 @@ "@mantine/core": "9.7.4", "@mantine/hooks": "9.7.4", "@noble/curves": "2.4.0", + "@xterm/addon-fit": "^0.11.0", + "@xterm/addon-web-links": "^0.12.0", + "@xterm/xterm": "^6.0.0", "codemirror": "^6.0.2", "docx-preview": "0.4.1", "dompurify": "^3.4.17", @@ -43,10 +46,10 @@ "katex": "^0.19.1", "lucide-react": "^1.64.0", "maplibre-gl": "6.16.0", - "react": "^19.3.1", - "react-dom": "^19.3.1", "papaparse": "^5.7.0", "pdfjs-dist": "^6.4.299", + "react": "^19.3.1", + "react-dom": "^19.3.1", "react-map-gl": "8.1.3", "react-markdown": "^10.1.0", "recharts": "3.10.1", diff --git a/desktop/frontend-next/pnpm-lock.yaml b/desktop/frontend-next/pnpm-lock.yaml index c6577705bd..76db9e1358 100644 --- a/desktop/frontend-next/pnpm-lock.yaml +++ b/desktop/frontend-next/pnpm-lock.yaml @@ -80,6 +80,15 @@ importers: '@noble/curves': specifier: 2.4.0 version: 2.4.0 + '@xterm/addon-fit': + specifier: ^0.11.0 + version: 0.11.0 + '@xterm/addon-web-links': + specifier: ^0.12.0 + version: 0.12.0 + '@xterm/xterm': + specifier: ^6.0.0 + version: 6.0.0 codemirror: specifier: ^6.0.2 version: 6.0.2 @@ -1150,6 +1159,15 @@ packages: '@vitest/spy@5.0.5': resolution: {integrity: sha512-lwJRExuH417257Q5wv9QYugN9IMJkoxcrKrYxGPQkBzl7wlNpTUxD/5yNX4ESKVUIShKPgkGchYPFMWOOk0wzw==} + '@xterm/addon-fit@0.11.0': + resolution: {integrity: sha512-jYcgT6xtVYhnhgxh3QgYDnnNMYTcf8ElbxxFzX0IZo+vabQqSPAjC3c1wJrKB5E19VwQei89QCiZZP86DCPF7g==} + + '@xterm/addon-web-links@0.12.0': + resolution: {integrity: sha512-4Smom3RPyVp7ZMYOYDoC/9eGJJJqYhnPLGGqJ6wOBfB8VxPViJNSKdgRYb8NpaM6YSelEKbA2SStD7lGyqaobw==} + + '@xterm/xterm@6.0.0': + resolution: {integrity: sha512-TQwDdQGtwwDt+2cgKDLn0IRaSxYu1tSUjgKarSDkUM0ZNiSRXFpjxEsvc/Zgc5kq5omJ+V0a8/kIM2WD3sMOYg==} + ansi-regex@5.0.1: resolution: {integrity: sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==} engines: {node: '>=8'} @@ -3865,6 +3883,12 @@ snapshots: '@vitest/spy@5.0.5': {} + '@xterm/addon-fit@0.11.0': {} + + '@xterm/addon-web-links@0.12.0': {} + + '@xterm/xterm@6.0.0': {} + ansi-regex@5.0.1: {} ansi-styles@5.2.0: {} diff --git a/desktop/frontend-next/src/actions_workbench.ts b/desktop/frontend-next/src/actions_workbench.ts index ffccdb7387..ad469b1e08 100644 --- a/desktop/frontend-next/src/actions_workbench.ts +++ b/desktop/frontend-next/src/actions_workbench.ts @@ -19,6 +19,24 @@ export const WORKBENCH_ACTIONS: UIAction[] = [ { id: "workbench.launch-find", kind: "view", target: "none", proof: "interaction" }, { id: "workbench.dismiss", kind: "view", target: "none", proof: "interaction" }, { id: "workbench.launch-browser", kind: "view", target: "none", proof: "interaction" }, + { id: "workbench.launch-terminal", kind: "view", target: "none", proof: "interaction" }, + { id: "terminal.select", kind: "view", target: "none", proof: "interaction" }, + { id: "terminal.shell-menu", kind: "view", target: "none", proof: "interaction" }, + { id: "terminal.shell", kind: "navigation", target: "entity", proof: "interaction" }, + { id: "terminal.clear", kind: "view", target: "none", proof: "interaction" }, + { id: "terminal.reconnect", kind: "view", target: "none", proof: "interaction" }, + { id: "terminal.more", kind: "view", target: "none", proof: "interaction" }, + { id: "terminal.copy-all", kind: "shell-native", target: "none", proof: "interaction" }, + { id: "terminal.size", kind: "view", target: "none", proof: "interaction" }, + { id: "terminal.note-dismiss", kind: "view", target: "none", proof: "interaction" }, + { id: "terminal.open-link", kind: "navigation", target: "none", proof: "interaction" }, + { id: "terminal.copy-link", kind: "shell-native", target: "none", proof: "interaction" }, + { id: "terminal.send-to-chat", kind: "view", target: "none", proof: "interaction" }, + { id: "terminal.copy-selection", kind: "shell-native", target: "none", proof: "interaction" }, + { id: "terminal.restart", kind: "kernel-mutation", target: "none", proof: "interaction" }, + { id: "terminal.close", kind: "kernel-mutation", target: "none", proof: "interaction" }, + { id: "terminal.close-confirm", kind: "kernel-mutation", target: "none", proof: "interaction" }, + { id: "terminal.close-cancel", kind: "view", target: "none", proof: "interaction" }, { id: "workbench.history-reload", kind: "repeatable", target: "none", proof: "interaction" }, { id: "workbench.tab", kind: "view", target: "entity", proof: "interaction" }, { id: "workbench.browser-menu", kind: "view", target: "entity", proof: "interaction" }, diff --git a/desktop/frontend-next/src/design/composer.css b/desktop/frontend-next/src/design/composer.css index 0ccfea817c..74975b4210 100644 --- a/desktop/frontend-next/src/design/composer.css +++ b/desktop/frontend-next/src/design/composer.css @@ -24,6 +24,9 @@ .compose .changedpill i[data-io="up"] { color: var(--add); } .compose .changedpill i[data-io="down"] { color: var(--del); } +.compose > .composeaux { overflow-y: auto; overscroll-behavior: contain; } +.compose > .composeaux > * { flex: none; min-height: auto; } +.compose:not(:has(.composenotes > *)) > .composeaux { max-height: var(--aux-budget); } .compose > .composeaux:not(:empty) { margin: 6px 10px 0; } .compose > .composeaux:not(:empty) ~ .changedpill-row { margin-top: 4px; } .compose > .composeaux:has(> .rtbar[data-kind="connect"]) { margin: 0; } diff --git a/desktop/frontend-next/src/design/design.css b/desktop/frontend-next/src/design/design.css index d1dc5cbbc6..143406e5a7 100644 --- a/desktop/frontend-next/src/design/design.css +++ b/desktop/frontend-next/src/design/design.css @@ -12,6 +12,7 @@ @import "./subagent.css" layer(design); @import "./jobs.css" layer(design); @import "./workbench.css" layer(design); +@import "./terminal.css" layer(design); @import "./palette.css" layer(design); @import "./rail.css" layer(design); @import "./feedback.css" layer(design); diff --git a/desktop/frontend-next/src/design/terminal.css b/desktop/frontend-next/src/design/terminal.css new file mode 100644 index 0000000000..0efe1484ee --- /dev/null +++ b/desktop/frontend-next/src/design/terminal.css @@ -0,0 +1,44 @@ +.term { display: flex; flex-direction: column; height: 100%; min-height: 0; background: var(--raised); } +.term[hidden] { display: none; } + +.term-note { display: flex; align-items: flex-start; gap: 8px; padding: 9px 12px; border-bottom: 1px solid var(--hair); background: var(--page); color: var(--muted); font: var(--w-med) var(--fs-meta)/1.6 var(--ui); } +.term-note > .studio-icon { flex: none; width: 14px; height: 14px; margin-top: 3px; color: var(--warn-ink); } +.term-note > p { flex: 1; margin: 0; } +.term-note > button { flex: none; padding: 0; border: 0; background: none; color: var(--text); font: var(--w-semi) var(--fs-meta)/1.6 var(--ui); white-space: nowrap; cursor: pointer; } + +.term-head { flex: none; display: flex; align-items: center; gap: 6px; height: 40px; box-sizing: border-box; padding: 0 8px 0 12px; border-bottom: 1px solid var(--hair); background: var(--raised); } +.term-grow { flex: 1; } +.term-dir { min-width: 0; overflow: hidden; color: var(--muted); font: var(--w-med) var(--fs-meta) var(--mono); text-overflow: ellipsis; white-space: nowrap; } +.term-shell { display: inline-flex; align-items: center; gap: 4px; height: 24px; padding: 0 8px; border: 1px solid var(--border); border-radius: var(--r-sm); background: none; color: var(--text); font: var(--w-med) var(--fs-meta)/1 var(--ui); cursor: pointer; } +.term-shell .studio-icon { width: 10px; height: 10px; } +.term-tool { display: inline-grid; place-items: center; flex: none; width: 28px; height: 28px; padding: 0; border: 0; border-radius: var(--r-sm); background: none; color: var(--muted); cursor: pointer; } +.term-tool:hover, .term-tool[aria-expanded="true"] { background: var(--overlay); color: var(--text); } +.term-tool .studio-icon { width: 16px; height: 16px; } + +.term-drop { position: relative; flex: none; } +.term-menu { position: absolute; z-index: 30; top: calc(100% + 4px); left: 0; width: 220px; box-sizing: border-box; padding: 5px; border: 1px solid var(--border); border-radius: var(--r-md); background: var(--float); box-shadow: var(--shadow-float); } +.term-head > .term-drop:last-child .term-menu { left: auto; right: 0; width: 180px; } +.term-menu > button { display: flex; align-items: center; gap: 8px; width: 100%; padding: 7px 9px; border: 0; border-radius: var(--r-sm); background: none; color: var(--text); font: var(--w-med) var(--fs-ui)/1.2 var(--ui); text-align: left; cursor: pointer; } +.term-menu > button:hover, .term-menu > button[data-current] { background: var(--float-hi); } +.term-menu > button > span { flex: 1; min-width: 0; } +.term-menu > button kbd { color: var(--faint); font: var(--w-med) var(--fs-micro) var(--mono); } +.term-menu > button .studio-icon { width: 14px; height: 14px; color: var(--accent-ink); } +.term-menu > p { margin: 0; padding: 6px 9px 3px; color: var(--faint); font: var(--w-med) var(--fs-micro)/1.5 var(--ui); } + +.term-body { position: relative; flex: 1; min-height: 0; padding: 10px 0 10px 12px; background: var(--raised); } +.term-screen { height: 100%; } +.term-screen .xterm { height: 100%; } +.term-screen .xterm-viewport { background-color: transparent !important; } + +.term-float { position: absolute; z-index: 20; display: flex; gap: 2px; padding: 3px; border: 1px solid var(--border); border-radius: var(--r-md); background: var(--float); box-shadow: var(--shadow-float); transform: translateY(8px); } +.term-float > button { display: inline-flex; align-items: center; gap: 5px; height: 24px; padding: 0 10px; border: 0; border-radius: var(--r-pill); background: none; color: var(--muted); font: var(--w-med) var(--fs-meta)/1 var(--ui); white-space: nowrap; cursor: pointer; } +.term-float > button:first-child { color: var(--text); } +.term-float > button:hover { background: var(--float-hi); } +.term-float .studio-icon { width: 13px; height: 13px; } + +.term-exit { flex: none; display: flex; align-items: center; gap: 8px; padding: 8px 12px; border-top: 1px solid var(--hair); background: var(--page); color: var(--muted); font: var(--w-med) var(--fs-meta)/1.4 var(--ui); } +.term-exit > span { flex: 1; } +.term-exit > button { display: inline-flex; align-items: center; height: 24px; padding: 0 10px; border: 1px solid transparent; border-radius: var(--r-pill); background: none; color: var(--muted); font: var(--w-med) var(--fs-meta)/1 var(--ui); white-space: nowrap; cursor: pointer; } +.term-exit > button[data-primary] { height: 32px; padding: 0 14px; border: 1px solid var(--border); border-radius: var(--r-sm); background: var(--raised); color: var(--text); font: var(--w-med) var(--fs-ui)/1 var(--ui); } + +.workbench-tab-busy { flex: none; width: 7px; height: 7px; border-radius: 50%; background: var(--net); box-shadow: 0 0 0 3px var(--net-wash); } diff --git a/desktop/frontend-next/src/i18n/kernel.ts b/desktop/frontend-next/src/i18n/kernel.ts index 8ff07940cc..8e43e4002f 100644 --- a/desktop/frontend-next/src/i18n/kernel.ts +++ b/desktop/frontend-next/src/i18n/kernel.ts @@ -117,6 +117,11 @@ const SAID: Record = { "push.git_failed": "kernel.git_failed_during_the_push", "git.repo_unknown": "kernel.that_repository_is_not_part", "git.repos_failed": "kernel.could_not_list_the_repositories", + "terminal.not_found": "kernel.that_terminal_is_not_open", + "terminal.shell_missing": "kernel.that_shell_is_not_on", + "terminal.start_failed": "kernel.the_shell_could_not_start", + "terminal.exited": "kernel.the_shell_has_ended", + "terminal.limit": "kernel.too_many_terminals_are_open", "workspace.file_missing": "kernel.the_file_was_not_found", "workspace.file_failed": "kernel.the_file_operation_failed_try", "workspace.path_outside_tree": "kernel.that_path_is_outside_the_2", diff --git a/desktop/frontend-next/src/i18n/msg/index.ts b/desktop/frontend-next/src/i18n/msg/index.ts index feba3bae13..e5bc78986e 100644 --- a/desktop/frontend-next/src/i18n/msg/index.ts +++ b/desktop/frontend-next/src/i18n/msg/index.ts @@ -19,6 +19,7 @@ import { SAFETY } from "./safety"; import { SESSION } from "./session"; import { SETTINGS } from "./settings"; import { SUBAGENT } from "./subagent"; +import { TERMINAL } from "./terminal"; import { USAGE } from "./usage"; import { WORKBENCH } from "./workbench"; import type { Messages } from "./types"; @@ -27,7 +28,7 @@ export type { Message, Messages } from "./types"; /** Every message by its key. Each area's table is one file; a key's first * segment names the file it lives in. */ -export const AREAS: Record = { about: ABOUT, app: APP, chat: CHAT, common: COMMON, composer: COMPOSER, data: DATA, ext: EXT, feedback: FEEDBACK, kernel: KERNEL, look: LOOK, memory: MEMORY, models: MODELS, onboarding: ONBOARDING, panels: PANELS, persona: PERSONA, plan: PLAN, remote: REMOTE, safety: SAFETY, session: SESSION, settings: SETTINGS, subagent: SUBAGENT, usage: USAGE, workbench: WORKBENCH }; +export const AREAS: Record = { about: ABOUT, app: APP, chat: CHAT, common: COMMON, composer: COMPOSER, data: DATA, ext: EXT, feedback: FEEDBACK, kernel: KERNEL, look: LOOK, memory: MEMORY, models: MODELS, onboarding: ONBOARDING, panels: PANELS, persona: PERSONA, plan: PLAN, remote: REMOTE, safety: SAFETY, session: SESSION, settings: SETTINGS, subagent: SUBAGENT, terminal: TERMINAL, usage: USAGE, workbench: WORKBENCH }; export const MESSAGES: Messages = Object.assign({}, ...Object.values(AREAS)); diff --git a/desktop/frontend-next/src/i18n/msg/kernel.ts b/desktop/frontend-next/src/i18n/msg/kernel.ts index 018aa5b3ca..f0e1845f53 100644 --- a/desktop/frontend-next/src/i18n/msg/kernel.ts +++ b/desktop/frontend-next/src/i18n/msg/kernel.ts @@ -366,11 +366,13 @@ export const KERNEL: Messages = { "kernel.that_server_would_not_start": { zh: "该服务器未能启动,且开关未能恢复——重启后将保持刚才设置的状态", en: "That server would not start, and the switch could not be put back - after a restart it will hold what you just set" }, "kernel.that_server_would_not_start_2": { zh: "该服务器未能启动,开关已恢复原状", en: "That server would not start; the switch has been put back" }, "kernel.that_session_does_not_accept": { zh: "对方不接收消息", en: "That session does not accept messages" }, + "kernel.that_shell_is_not_on": { zh: "这台机器上没有这个终端程序", en: "That shell is not on this machine" }, "kernel.that_side_question_is_closed": { zh: "这个辅助提问已经关闭,请重新打开", en: "That side question is closed; open it again" }, "kernel.that_source_could_not_be": { zh: "无法读取这个来源:{detail}", en: "That source could not be read: {detail}" }, "kernel.that_sub_agent_belongs_to": { zh: "这个子代理不属于当前会话", en: "That sub-agent belongs to a different conversation" }, "kernel.that_sub_agent_is_no": { zh: "这个子代理已经不在运行,读不到新的指示", en: "That sub-agent is no longer running, so it cannot read new guidance" }, "kernel.that_submission_key_was_already": { zh: "该提交标识已被使用,且当时的内容不同", en: "That submission key was already used, with different content" }, + "kernel.that_terminal_is_not_open": { zh: "这个终端已经不在了", en: "That terminal is no longer open" }, "kernel.that_theme_could_not_be": { zh: "无法读取该主题:{detail}", en: "That theme could not be read: {detail}" }, "kernel.that_update_mode_is_not": { zh: "不支持这种更新方式,没有改动。", en: "That update mode is not supported; nothing was changed." }, "kernel.that_value_is_out_of": { zh: "该数值超出允许范围,未做任何修改:{detail}", en: "That value is out of range; nothing was changed: {detail}" }, @@ -593,6 +595,8 @@ export const KERNEL: Messages = { "kernel.the_session_workspace_or_runtime": { zh: "会话、工作区或运行时正在切换,这条消息没能立刻发出;它已排入队列,切换结束后会自动发送,不需要重发", en: "The session, workspace or runtime is switching, so this message could not be sent right away; it is queued and will be sent automatically when the switch ends - no need to send it again" }, "kernel.the_settings_were_written_but": { zh: "设置已写入,但运行时未能按新设置重建:{detail}", en: "The settings were written, but the runtime could not be rebuilt on them: {detail}" }, "kernel.the_sharing_listener_is_not": { zh: "共享入口配置不完整,无法接受连接。", en: "The sharing listener is not fully configured and cannot accept connections." }, + "kernel.the_shell_could_not_start": { zh: "终端没能启动", en: "The terminal could not start" }, + "kernel.the_shell_has_ended": { zh: "终端里的程序已经结束,重新启动后再输入", en: "The shell has ended; restart it before typing" }, "kernel.the_shell_settings_were_not": { zh: "shell 设置未能保存:{detail}", en: "The shell settings were not saved: {detail}" }, "kernel.the_side_question_could_not": { zh: "辅助提问没能完成,请重试", en: "The side question could not be completed; try again" }, "kernel.the_side_questions_open_at": { zh: "同时打开的辅助提问已到你设置的上限(agent.max_side_sessions),请先关闭几个", en: "The side questions open at once have reached your limit (agent.max_side_sessions); close some first" }, @@ -809,6 +813,7 @@ export const KERNEL: Messages = { "kernel.too_many_panel_placements_are": { zh: "记录的面板位置已达上限(最多 {limit} 个),请先清除一个", en: "Too many panel placements are recorded (at most {limit}); clear one first" }, "kernel.too_many_requests_wait_a": { zh: "请求太频繁,请稍等一分钟再试", en: "Too many requests - wait a minute and try again" }, "kernel.too_many_submissions_try_again": { zh: "提交太频繁了,请稍后再试", en: "Too many submissions - try again a little later" }, + "kernel.too_many_terminals_are_open": { zh: "打开的终端太多了,先关掉几个", en: "Too many terminals are open; close some first" }, "kernel.too_much_guidance_was_sent": { zh: "短时间内发给这个子代理的指示太多了,请稍后再发", en: "Too much guidance was sent to this sub-agent a moment ago; send it again shortly" }, "kernel.uncommitted_changes_would_be_overwritten": { zh: "有未提交的修改会被这次切换覆盖,请先提交或暂存(git stash)", en: "Uncommitted changes would be overwritten by the switch; commit or stash them first" }, "kernel.unrecognised_reasoning_protocol_protocol": { zh: "无法识别「{protocol}」这种思考协议", en: "Unrecognised reasoning protocol “{protocol}”" }, diff --git a/desktop/frontend-next/src/i18n/msg/terminal.ts b/desktop/frontend-next/src/i18n/msg/terminal.ts new file mode 100644 index 0000000000..672c08e808 --- /dev/null +++ b/desktop/frontend-next/src/i18n/msg/terminal.ts @@ -0,0 +1,28 @@ +import type { Messages } from "./types"; + +export const TERMINAL: Messages = { + "terminal.clear": { zh: "清屏", en: "Clear" }, + "terminal.close_a_running_one": { zh: "这个终端里还有命令在运行,关闭会结束它。", en: "A command is still running in this terminal; closing it ends the command." }, + "terminal.close_and_end": { zh: "结束并关闭", en: "End and close" }, + "terminal.copy_all": { zh: "复制全部", en: "Copy everything" }, + "terminal.copy_link": { zh: "复制链接", en: "Copy link" }, + "terminal.could_not_open": { zh: "没能打开终端:{why}", en: "Could not open a terminal: {why}" }, + "terminal.default_shell_note": { zh: "只列这台电脑上找得到的;默认用上次选的", en: "Only what this computer has is listed; the one you chose last is the default" }, + "terminal.exited": { zh: "进程已退出(代码 {code})", en: "The process exited (code {code})" }, + "terminal.font_larger": { zh: "放大字号", en: "Larger text" }, + "terminal.font_reset": { zh: "恢复字号", en: "Default text size" }, + "terminal.font_smaller": { zh: "缩小字号", en: "Smaller text" }, + "terminal.launch_hint": { zh: "在这个项目的目录里打开你自己的终端", en: "Open your own terminal in this project's folder" }, + "terminal.more": { zh: "更多:复制全部、字体大小", en: "More: copy everything, text size" }, + "terminal.n": { zh: "终端 {n}", en: "Terminal {n}" }, + "terminal.name": { zh: "终端", en: "Terminal" }, + "terminal.not_the_models": { zh: "这是你自己的终端:以你的身份运行,不经过模型的权限和沙盒。模型看不到这里,除非你把内容发给它。", en: "This is your own terminal: it runs as you, outside the model's permissions and sandbox. The model cannot see it unless you send it something." }, + "terminal.open": { zh: "打开终端", en: "Open the terminal" }, + "terminal.open_in_browser": { zh: "在内置浏览器打开", en: "Open in the built-in browser" }, + "terminal.pick_a_shell": { zh: "选择终端程序", en: "Choose a shell" }, + "terminal.reconnect": { zh: "重新连接", en: "Reconnect" }, + "terminal.restart": { zh: "重新启动", en: "Restart" }, + "terminal.send_to_chat": { zh: "发给对话", en: "Send to chat" }, + "terminal.shell_picker": { zh: "终端程序", en: "Shell" }, + "terminal.the_terminal": { zh: "终端输出", en: "Terminal output" }, +}; diff --git a/desktop/frontend-next/src/port/mock_terminal.ts b/desktop/frontend-next/src/port/mock_terminal.ts new file mode 100644 index 0000000000..4eb59a3522 --- /dev/null +++ b/desktop/frontend-next/src/port/mock_terminal.ts @@ -0,0 +1,115 @@ +import { HttpError } from "./http_error"; +import { MockToolOutput } from "./mock_tooloutput"; +import type { ShellChoice, TerminalFrame, TerminalInfo, TerminalStream } from "./terminal"; + +const SHELLS: ShellChoice[] = [ + { name: "Git Bash", kind: "bash", path: "/usr/bin/bash" }, + { name: "PowerShell", kind: "pwsh", path: "/usr/bin/pwsh" }, + { name: "命令提示符", kind: "cmd", path: "cmd.exe" }, +]; + +const PROMPT = "\x1b[36m~/work/reasonix\x1b[0m \x1b[32m(main)\x1b[0m $ "; + +interface Fake { + info: TerminalInfo; + held: string; + line: string; + readers: Set<(f: TerminalFrame) => void>; +} + +const encode = (text: string) => btoa(String.fromCharCode(...new TextEncoder().encode(text))); + +// A shell that echoes what is typed and answers a few commands, enough to draw +// every state of the terminal page without a kernel. +export class MockTerminal extends MockToolOutput { + private fakes = new Map(); + private serial = 0; + + private say(fake: Fake, text: string) { + fake.held += text; + for (const reader of fake.readers) reader({ kind: "out", data: encode(text) }); + } + + async terminalShells() { + return SHELLS; + } + + async terminals() { + return [...this.fakes.values()].map((f) => ({ ...f.info })); + } + + async openTerminal(shell: string, cols: number, rows: number) { + const choice = SHELLS.find((s) => !shell || s.kind === shell); + if (!choice) throw new HttpError(400, "that shell is not on this machine", { code: "terminal.shell_missing" }); + const info: TerminalInfo = { id: `t${(this.serial += 1)}`, name: choice.name, shell: choice.path, dir: "~/work/reasonix", cols, rows, running: true, busy: false, created: new Date().toISOString() }; + const fake: Fake = { info, held: PROMPT, line: "", readers: new Set() }; + this.fakes.set(info.id, fake); + return { ...info }; + } + + private fake(id: string) { + const fake = this.fakes.get(id); + if (!fake) throw new HttpError(404, "no such terminal", { code: "terminal.not_found" }); + return fake; + } + + async closeTerminal(id: string) { + this.fake(id); + this.fakes.delete(id); + } + + async restartTerminal(id: string) { + const fake = this.fake(id); + if (!fake.info.running) { + fake.info = { ...fake.info, running: true, exitCode: undefined }; + fake.held = PROMPT; + } + return { ...fake.info }; + } + + async resizeTerminal(id: string, cols: number, rows: number) { + const fake = this.fake(id); + fake.info = { ...fake.info, cols, rows }; + } + + async terminalInput(id: string, data: string) { + const fake = this.fake(id); + if (!fake.info.running) throw new HttpError(409, "the shell has ended", { code: "terminal.exited" }); + for (const ch of data) { + if (ch === "\r") { + const command = fake.line.trim(); + fake.line = ""; + this.say(fake, "\r\n"); + if (command === "exit") { + fake.info = { ...fake.info, running: false, exitCode: 130 }; + for (const reader of fake.readers) reader({ kind: "exit", code: 130 }); + return; + } + if (command) this.say(fake, command.startsWith("echo ") ? command.slice(5) + "\r\n" : `${command}: command not found\r\n`); + this.say(fake, PROMPT); + } else if (ch === "\x7f") { + if (fake.line) { + fake.line = fake.line.slice(0, -1); + this.say(fake, "\b \b"); + } + } else if (ch >= " ") { + fake.line += ch; + this.say(fake, ch); + } + } + } + + streamTerminal(id: string, onFrame: (frame: TerminalFrame) => void, onEnd: () => void): TerminalStream { + const fake = this.fakes.get(id); + if (!fake) { + queueMicrotask(onEnd); + return { close() {} }; + } + queueMicrotask(() => { + onFrame({ kind: "replay", data: encode(fake.held) }); + if (!fake.info.running) onFrame({ kind: "exit", code: fake.info.exitCode }); + }); + fake.readers.add(onFrame); + return { close: () => void fake.readers.delete(onFrame) }; + } +} diff --git a/desktop/frontend-next/src/port/mock_workspace.ts b/desktop/frontend-next/src/port/mock_workspace.ts index 05f20b5b81..b952434691 100644 --- a/desktop/frontend-next/src/port/mock_workspace.ts +++ b/desktop/frontend-next/src/port/mock_workspace.ts @@ -2,7 +2,7 @@ import { reason } from "../i18n/kernel"; import { HttpError } from "./http_error"; import type { KeptText } from "./attachment"; import type { ChangeDiff, GitHistory, StageRequest, WorkspaceChange, WorkspaceChanges, WorkspaceGit, WorkspaceBranches } from "./workspace"; -import { MockToolOutput } from "./mock_tooloutput"; +import { MockTerminal } from "./mock_terminal"; export function mockWorkspaces(root: string): import("./workspace").WorkspaceInfo { return { current: root, canSwitch: true, canIsolate: true, recents: [ @@ -19,7 +19,7 @@ export function mockWorktreeSources(root: string, branch: string): import("./wor ] }; } -export class MockWorkspace extends MockToolOutput { +export class MockWorkspace extends MockTerminal { async workspaceFile(path: string, _root?: string): Promise { return { path, content: `// ${path}\n`, revision: "fixture" }; } async workspacePreviewInfo(ref: import("./workspace").FileRef): Promise { const file = await this.workspaceFile(ref.path, ref.root); diff --git a/desktop/frontend-next/src/port/port.ts b/desktop/frontend-next/src/port/port.ts index b5d990e4e8..38eaa2918b 100644 --- a/desktop/frontend-next/src/port/port.ts +++ b/desktop/frontend-next/src/port/port.ts @@ -50,6 +50,7 @@ import type { AskTimeoutSettings, ModStatus, ModsSettings, ModsSettingsEdit, Ses import type { ScheduleBoard, ScheduleDraft, SchedulePreview, ScheduleResultView, ScheduleView } from "./schedule"; import type { Protocol, ProviderHealth, ProviderCheck, ProviderCheckDraft, ProviderDraft, ProviderEdit, ProviderEntry, ProviderModelCheck, ProviderModelCheckRequest, ProviderProbe, ProviderSetup, ProviderRemoval, ProviderPreset, ProviderPresetInstall } from "./provider"; export type { ModelEffort, ModelLimit, ModelRate, Protocol, ProviderHealth, ProviderCheck, ProviderCheckDraft, ProviderDraft, ProviderEdit, ProviderEntry, ProviderModelCheck, ProviderModelCheckRequest, ProviderProbe, ProviderSetup, ProviderRemoval, ProviderPreset, ProviderPresetInstall } from "./provider"; +import type { TerminalPort } from "./terminal"; import type { StoragePlan, StorageQuery, StorageState } from "./storage"; import type { FeedbackBoard, FeedbackBoardMe, FeedbackEnv, FeedbackImage, FeedbackLinkedInstall, FeedbackMine, FeedbackReceipt, FeedbackReplyReceipt, FeedbackRequest } from "./feedback"; import type { PersonaDraft, PersonaSettings, PresentFilesSettings } from "./boundary"; @@ -180,7 +181,7 @@ export interface TrayPrefs { closeToTray: boolean; } -export interface AgentPort extends WorkspacePreviewPort, SessionTailPort, UploadUsagePort { +export interface AgentPort extends WorkspacePreviewPort, SessionTailPort, UploadUsagePort, TerminalPort { providerSetup(): Promise; saveProviderKey(apiKey: string): Promise; // The models that answer the job asked about; "chat" when none is named. diff --git a/desktop/frontend-next/src/port/sse.ts b/desktop/frontend-next/src/port/sse.ts index 6397094d74..f0409e0e79 100644 --- a/desktop/frontend-next/src/port/sse.ts +++ b/desktop/frontend-next/src/port/sse.ts @@ -2,7 +2,7 @@ import type { SurfaceActionRequest, SurfaceActionRecord } from "./surface"; import { PLAN_ACTIONS, type PlanAction } from "./session"; import type { AccountState, AgentPort, ChipCall, CompactionSettings, Completion, DeviceGrant, UpdateProgress, StudioLogs, VersionHub, VersionNotes, ApprovalMode, ApprovalVerdict, Checkpoint, RewindPlan, RewindResult, RewindUndo, RewindScope, HistoryMessage, HostTodo, BrowserTab, ModelEntry, Preset, ProviderSetup, RoleAssignments, RoleOverride, SessionEntry, SessionStatus, WalletReading, MemoryCatalog, MemoryEdit, MemoryEntry, UsageQuery, UsageReport, UploadReport, Queue, Queued, NotifyPrefs, TrayPrefs, WorkspaceInfo } from "./port"; import { HttpError, type CommitProposal, type CommitRequest, type CommitResult, type WorkspaceFile, type WorkspaceFiles } from "./port"; -import { SseWorkspace } from "./sse_workspace"; +import { SseTerminal } from "./sse_terminal"; import type { StoragePlan, StorageQuery, StorageState } from "./storage"; import type { ExecutionGraphRead, TrajectoryRead, WireEvent } from "./wire"; import { host } from "./host"; @@ -17,7 +17,7 @@ import { subscribeStream } from "./sse_subscribe"; // a load. The read is one small JSON body and answers from memory. const UPDATE_POLL_MS = 500; -export class SsePort extends SseWorkspace implements AgentPort { +export class SsePort extends SseTerminal implements AgentPort { status() { return this.get("/status"); } diff --git a/desktop/frontend-next/src/port/sse_terminal.ts b/desktop/frontend-next/src/port/sse_terminal.ts new file mode 100644 index 0000000000..1d20a26b0d --- /dev/null +++ b/desktop/frontend-next/src/port/sse_terminal.ts @@ -0,0 +1,43 @@ +import { SseWorkspace } from "./sse_workspace"; +import type { ShellChoice, TerminalFrame, TerminalInfo, TerminalStream } from "./terminal"; + +export class SseTerminal extends SseWorkspace { + terminalShells() { + return this.get("/terminals/shells"); + } + terminals() { + return this.get("/terminals"); + } + openTerminal(shell: string, cols: number, rows: number) { + return this.post0("/terminals", { shell, cols, rows }); + } + closeTerminal(id: string) { + return this.del("/terminals/" + encodeURIComponent(id)); + } + restartTerminal(id: string) { + return this.post0("/terminals/" + encodeURIComponent(id) + "/restart"); + } + resizeTerminal(id: string, cols: number, rows: number) { + return this.post("/terminals/" + encodeURIComponent(id) + "/resize", { cols, rows }); + } + terminalInput(id: string, data: string) { + return this.post("/terminals/" + encodeURIComponent(id) + "/input", { data }); + } + // A stream that ends does not reconnect by itself: the exit frame is the + // normal end, and a dropped connection is for the view to reopen with replay. + streamTerminal(id: string, onFrame: (frame: TerminalFrame) => void, onEnd: () => void): TerminalStream { + const source = new EventSource(`${this.base}/terminals/${encodeURIComponent(id)}/stream`, { withCredentials: true }); + source.onmessage = (e) => { + try { + onFrame(JSON.parse(e.data) as TerminalFrame); + } catch { + // A frame that is not ours carries nothing to draw. + } + }; + source.onerror = () => { + source.close(); + onEnd(); + }; + return { close: () => source.close() }; + } +} diff --git a/desktop/frontend-next/src/port/terminal.ts b/desktop/frontend-next/src/port/terminal.ts new file mode 100644 index 0000000000..c973d0ffb6 --- /dev/null +++ b/desktop/frontend-next/src/port/terminal.ts @@ -0,0 +1,50 @@ +// The person's own terminals: shells they type into, apart from the model's. + +export interface TerminalInfo { + id: string; + name: string; + shell: string; + dir: string; + cols: number; + rows: number; + running: boolean; + exitCode?: number; + busy: boolean; + created: string; +} + +// An interpreter this machine has. kind is the short tag beside the name and is +// what openTerminal takes to pick it. +export interface ShellChoice { + name: string; + kind: string; + path: string; + args?: string[]; +} + +// What a terminal's stream carries. replay is what the shell wrote before this +// reader came; out is what it writes after; exit ends the stream with the code. +export interface TerminalFrame { + kind: "replay" | "out" | "exit"; + data?: string; + code?: number; +} + +export interface TerminalStream { + close(): void; +} + +export interface TerminalPort { + terminalShells(): Promise; + terminals(): Promise; + openTerminal(shell: string, cols: number, rows: number): Promise; + closeTerminal(id: string): Promise; + restartTerminal(id: string): Promise; + resizeTerminal(id: string, cols: number, rows: number): Promise; + // The keystrokes as typed. A refusal says the shell has ended; the exit footer + // is already showing then, so a caller may let it go. + terminalInput(id: string, data: string): Promise; + // Reads the terminal from what it already held. onEnd fires when the stream + // stops for any reason, so a caller knows to reopen it rather than wait. + streamTerminal(id: string, onFrame: (frame: TerminalFrame) => void, onEnd: () => void): TerminalStream; +} diff --git a/desktop/frontend-next/src/ui/App.tsx b/desktop/frontend-next/src/ui/App.tsx index e118b01c46..06fc6606b7 100644 --- a/desktop/frontend-next/src/ui/App.tsx +++ b/desktop/frontend-next/src/ui/App.tsx @@ -130,6 +130,7 @@ export function App({ hub }: { hub: HubPort }) { const [dockLimit, setDockLimit] = useState(() => dockMax(document.body.clientWidth)); const [report, setReport] = useState(NO_REPORT); const [filesRequests, setFilesRequests] = useState>({}); + const [terminalRequests, setTerminalRequests] = useState>({}); const [viewRequests, setViewRequests] = useState>({}); const [sessionRefs, referenceSession] = usePaneSessionRefs(active, (id) => panePorts.has(id)); const [error, setError] = useState(""); @@ -359,6 +360,12 @@ export function App({ hub }: { hub: HubPort }) { setFilesRequests((requests) => ({ ...requests, [active]: (requests[active] ?? 0) + 1 })); }, [active, panePorts, hidePrefs]); + const openTerminal = useCallback(() => { + if (!panePorts.has(active)) return; + hidePrefs(); + setTerminalRequests((requests) => ({ ...requests, [active]: (requests[active] ?? 0) + 1 })); + }, [active, panePorts, hidePrefs]); + const showView = useCallback((to: PaneView) => { if (!panePorts.has(active)) return; hidePrefs(); @@ -416,9 +423,10 @@ export function App({ hub }: { hub: HubPort }) { { chord: "1", fields: true, action: "pane.view", run: () => showView("flow") }, { chord: "2", fields: true, action: "pane.view", run: () => showView("analysis") }, { chord: "3", fields: true, action: "pane.view", run: () => showView("browser") }, + { chord: "`", fields: true, action: "workbench.launch-terminal", run: openTerminal }, ...zoomKeys, ], - [showPrefs, find.open, showView, zoomKeys], + [showPrefs, find.open, showView, openTerminal, zoomKeys], ); const closeBrowser = useCallback(() => setBrowser(false), []); const stopTurn = useCallback(() => activePort?.cancel(), [activePort]); @@ -688,6 +696,7 @@ export function App({ hub }: { hub: HubPort }) { findPulse={find.pulse} findTogglePulse={find.togglePulse} filesPulse={filesRequests[rt.id] ?? 0} + terminalPulse={terminalRequests[rt.id] ?? 0} viewRequest={viewRequests[rt.id]} sessionRef={sessionRefs[rt.id]} alert={rt.id === active ? bar : undefined} diff --git a/desktop/frontend-next/src/ui/KeysSheet.tsx b/desktop/frontend-next/src/ui/KeysSheet.tsx index d59199e9cb..f900e99823 100644 --- a/desktop/frontend-next/src/ui/KeysSheet.tsx +++ b/desktop/frontend-next/src/ui/KeysSheet.tsx @@ -9,6 +9,7 @@ const KEYS: Array<[string, string]> = [ [chord("1"), "session.show_conversation"], [chord("2"), "session.show_run_analysis"], [chord("3"), "session.show_workbench"], + [chord("`"), "terminal.open"], [`${chord("↑")} / ${chord("↓")}`, "settings.move_between_your_questions"], [chord("Shift+E"), "composer.refine_prompt"], [chord("Shift+F"), "settings.workbench_full_screen"], diff --git a/desktop/frontend-next/src/ui/ManualBrowsers.tsx b/desktop/frontend-next/src/ui/ManualBrowsers.tsx new file mode 100644 index 0000000000..222180442a --- /dev/null +++ b/desktop/frontend-next/src/ui/ManualBrowsers.tsx @@ -0,0 +1,8 @@ +import { ManualBrowserPanel } from "./BrowserPanel"; + +/** The browser pages the workbench draws itself, one panel each, only the one in front shown. */ +export function ManualBrowsers({ ids, active, onAddress, onExternal, scheme }: { + ids: string[]; active: string; onAddress: (id: string, host: string) => void; onExternal: (url: string) => void; scheme: "light" | "dark"; +}) { + return <>{ids.map((id) =>