mirror of
https://github.com/esengine/DeepSeek-Reasonix.git
synced 2026-11-07 02:36:01 -10:00
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.
This commit is contained in:
1 parent
3cf430d823
commit
83bdeb81f0
60 files changed
+2748
-48
No files matched your search
@@ -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",
|
||||
|
||||
Generated
+24
@@ -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/[email protected]':
|
||||
resolution: {integrity: sha512-lwJRExuH417257Q5wv9QYugN9IMJkoxcrKrYxGPQkBzl7wlNpTUxD/5yNX4ESKVUIShKPgkGchYPFMWOOk0wzw==}
|
||||
|
||||
'@xterm/[email protected]':
|
||||
resolution: {integrity: sha512-jYcgT6xtVYhnhgxh3QgYDnnNMYTcf8ElbxxFzX0IZo+vabQqSPAjC3c1wJrKB5E19VwQei89QCiZZP86DCPF7g==}
|
||||
|
||||
'@xterm/[email protected]':
|
||||
resolution: {integrity: sha512-4Smom3RPyVp7ZMYOYDoC/9eGJJJqYhnPLGGqJ6wOBfB8VxPViJNSKdgRYb8NpaM6YSelEKbA2SStD7lGyqaobw==}
|
||||
|
||||
'@xterm/[email protected]':
|
||||
resolution: {integrity: sha512-TQwDdQGtwwDt+2cgKDLn0IRaSxYu1tSUjgKarSDkUM0ZNiSRXFpjxEsvc/Zgc5kq5omJ+V0a8/kIM2WD3sMOYg==}
|
||||
|
||||
[email protected]:
|
||||
resolution: {integrity: sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==}
|
||||
engines: {node: '>=8'}
|
||||
@@ -3865,6 +3883,12 @@ snapshots:
|
||||
|
||||
'@vitest/[email protected]': {}
|
||||
|
||||
'@xterm/[email protected]': {}
|
||||
|
||||
'@xterm/[email protected]': {}
|
||||
|
||||
'@xterm/[email protected]': {}
|
||||
|
||||
[email protected]: {}
|
||||
|
||||
[email protected]: {}
|
||||
|
||||
@@ -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" },
|
||||
|
||||
@@ -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; }
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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); }
|
||||
@@ -117,6 +117,11 @@ const SAID: Record<string, string> = {
|
||||
"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",
|
||||
|
||||
@@ -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<string, Messages> = { 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<string, Messages> = { 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));
|
||||
|
||||
|
||||
@@ -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}”" },
|
||||
|
||||
@@ -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" },
|
||||
};
|
||||
@@ -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<string, Fake>();
|
||||
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) };
|
||||
}
|
||||
}
|
||||
@@ -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<import("./workspace").WorkspaceFile> { return { path, content: `// ${path}\n`, revision: "fixture" }; }
|
||||
async workspacePreviewInfo(ref: import("./workspace").FileRef): Promise<import("./workspace").PreviewInfo> {
|
||||
const file = await this.workspaceFile(ref.path, ref.root);
|
||||
|
||||
@@ -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<ProviderSetup | null>;
|
||||
saveProviderKey(apiKey: string): Promise<void>;
|
||||
// The models that answer the job asked about; "chat" when none is named.
|
||||
|
||||
@@ -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<SessionStatus>("/status");
|
||||
}
|
||||
|
||||
@@ -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<ShellChoice[]>("/terminals/shells");
|
||||
}
|
||||
terminals() {
|
||||
return this.get<TerminalInfo[]>("/terminals");
|
||||
}
|
||||
openTerminal(shell: string, cols: number, rows: number) {
|
||||
return this.post0<TerminalInfo>("/terminals", { shell, cols, rows });
|
||||
}
|
||||
closeTerminal(id: string) {
|
||||
return this.del("/terminals/" + encodeURIComponent(id));
|
||||
}
|
||||
restartTerminal(id: string) {
|
||||
return this.post0<TerminalInfo>("/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() };
|
||||
}
|
||||
}
|
||||
@@ -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<ShellChoice[]>;
|
||||
terminals(): Promise<TerminalInfo[]>;
|
||||
openTerminal(shell: string, cols: number, rows: number): Promise<TerminalInfo>;
|
||||
closeTerminal(id: string): Promise<void>;
|
||||
restartTerminal(id: string): Promise<TerminalInfo>;
|
||||
resizeTerminal(id: string, cols: number, rows: number): Promise<void>;
|
||||
// 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<void>;
|
||||
// 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;
|
||||
}
|
||||
@@ -130,6 +130,7 @@ export function App({ hub }: { hub: HubPort }) {
|
||||
const [dockLimit, setDockLimit] = useState(() => dockMax(document.body.clientWidth));
|
||||
const [report, setReport] = useState<PaneReport>(NO_REPORT);
|
||||
const [filesRequests, setFilesRequests] = useState<Record<string, number>>({});
|
||||
const [terminalRequests, setTerminalRequests] = useState<Record<string, number>>({});
|
||||
const [viewRequests, setViewRequests] = useState<Record<string, { to: PaneView; n: number }>>({});
|
||||
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}
|
||||
|
||||
@@ -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"],
|
||||
|
||||
@@ -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) => <ManualBrowserPanel key={id} id={id} hidden={active !== id} onAddress={onAddress} onExternal={onExternal} scheme={scheme} />)}</>;
|
||||
}
|
||||
@@ -100,7 +100,7 @@ const totalsOf = (st: SessionStatus) => ({
|
||||
incompleteReason: st.sessionCostQuote?.incompleteReason,
|
||||
});
|
||||
|
||||
function PaneView({ port, hub, rt, title, active, visible, sideHost, side, onFocus, onReport, onSessionChanged, onTurnDone, pulse, findPulse, findTogglePulse = 0, filesPulse = 0, viewRequest, sessionRef, onSettings, onFeedback, onOpenSession, needsProject, onOpenProject, onKeepHere, connect, theme, dockW, dockMax, onDockW, manualBrowser = false, onManualBrowser, alert }: PaneProps) {
|
||||
function PaneView({ port, hub, rt, title, active, visible, sideHost, side, onFocus, onReport, onSessionChanged, onTurnDone, pulse, findPulse, findTogglePulse = 0, filesPulse = 0, terminalPulse = 0, viewRequest, sessionRef, onSettings, onFeedback, onOpenSession, needsProject, onOpenProject, onKeepHere, connect, theme, dockW, dockMax, onDockW, manualBrowser = false, onManualBrowser, alert }: PaneProps) {
|
||||
const [s, dispatch] = useReducer(reduce, initialState);
|
||||
const [traj, trajDispatch] = useReducer(reduceTraj, initialTraj);
|
||||
const [sessionRead, setSessionRead] = useState<SessionRead>({ kind: "pending" });
|
||||
@@ -368,6 +368,7 @@ function PaneView({ port, hub, rt, title, active, visible, sideHost, side, onFoc
|
||||
}
|
||||
}, [visible, workbench, showView]);
|
||||
useOnBump(filesPulse, pressFiles);
|
||||
useOnBump(terminalPulse, () => showView("browser"));
|
||||
useOnBump(sessionRef?.n ?? 0, () => sessionRef && setInserted((was) => ({ n: was.n + 1, text: "", session: { id: sessionRef.id, title: sessionRef.title } })));
|
||||
useOnBump(viewRequest?.n ?? 0, () => {
|
||||
if (viewRequest && (viewRequest.to !== "analysis" || traj.rows.length > 0)) showView(viewRequest.to);
|
||||
@@ -603,7 +604,7 @@ function PaneView({ port, hub, rt, title, active, visible, sideHost, side, onFoc
|
||||
onSurfaces={setSurfaces}
|
||||
onActiveFile={onActiveFile}
|
||||
scheme={theme === "light" ? "light" : "dark"}
|
||||
changes={tree?.changes ?? []} onTreeChanged={refreshTree} revealChanges={revealChanges} revealFiles={revealFiles} toggleFiles={toggleFiles}
|
||||
changes={tree?.changes ?? []} onTreeChanged={refreshTree} revealChanges={revealChanges} revealFiles={revealFiles} toggleFiles={toggleFiles} terminalRequest={terminalPulse}
|
||||
running={running}
|
||||
wrote={counts.wrote}
|
||||
remote={!!rt.host}
|
||||
|
||||
@@ -35,6 +35,8 @@ const PATHS = {
|
||||
spark: "m12 3 2.5 6.5L21 12l-6.5 2.5L12 21l-2.5-6.5L3 12l6.5-2.5L12 3z",
|
||||
agent: "M11 3l1.7 4.3L17 9l-4.3 1.7L11 15l-1.7-4.3L5 9l4.3-1.7L11 3zM19 14l.8 2.2L22 17l-2.2.8L19 20l-.8-2.2L16 17l2.2-.8L19 14zM4 3l.5 1.5L6 5l-1.5.5L4 7l-.5-1.5L2 5l1.5-.5L4 3z",
|
||||
refresh: "M20 7a9 9 0 1 0 1 8M20 3v5h-5",
|
||||
terminal: "M3 5h18v14H3V5zM7.5 10l3 2.5-3 2.5M12.5 15H16",
|
||||
eraser: "M4.5 19.5h15M7.5 15l9-9M12 6l4.5 4.5",
|
||||
clock: "M12 7v5l3 2M21 12a9 9 0 1 1-18 0 9 9 0 0 1 18 0",
|
||||
stop: "M6 6h12v12H6z",
|
||||
play: "m8 4 12 8-12 8V4z",
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
import { createPortal } from "react-dom";
|
||||
import { t } from "../i18n";
|
||||
import { pinToViewport } from "./place";
|
||||
|
||||
/** Asked before a terminal with a command running is closed: ending the shell ends the command. */
|
||||
export function TerminalCloseAsk({ anchor, onConfirm, onCancel }: { anchor: HTMLElement; onConfirm: () => void; onCancel: () => void }) {
|
||||
const r = anchor.getBoundingClientRect();
|
||||
return createPortal(
|
||||
<div className="workbench-close-ask" role="alertdialog" aria-label={t("terminal.close_a_running_one")}
|
||||
data-action-keydown="layer.dismiss" ref={(el) => { if (el) pinToViewport(el, r.left, r.bottom + 4); }}
|
||||
onKeyDown={(e) => { if (e.key === "Escape") { e.stopPropagation(); onCancel(); } }}>
|
||||
<p>{t("terminal.close_a_running_one")}</p>
|
||||
<div>
|
||||
<button type="button" autoFocus data-action="terminal.close-confirm" onClick={onConfirm}>{t("terminal.close_and_end")}</button>
|
||||
<button type="button" data-action="terminal.close-cancel" onClick={onCancel}>{t("common.cancel")}</button>
|
||||
</div>
|
||||
</div>,
|
||||
document.body,
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
import { useEffect, useRef, useState, type ReactNode } from "react";
|
||||
import { t } from "../i18n";
|
||||
import type { ShellChoice } from "../port/terminal";
|
||||
import { listenAction } from "./listen";
|
||||
import { StudioIcon } from "./StudioIcon";
|
||||
|
||||
// Closes a dropdown when the press lands outside it or Escape is pressed.
|
||||
function useDismissed(open: boolean, close: () => void) {
|
||||
const box = useRef<HTMLDivElement>(null);
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
return listenAction(document, "pointerdown", { action: "layer.dismiss", listener: (e: Event) => { if (!box.current?.contains(e.target as Node)) close(); } });
|
||||
}, [open, close]);
|
||||
return box;
|
||||
}
|
||||
|
||||
function Drop({ label, button, children, onOpen }: { label: string; button: (toggle: () => void, open: boolean) => ReactNode; children: (close: () => void) => ReactNode; onOpen?: () => void }) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const close = () => setOpen(false);
|
||||
const box = useDismissed(open, close);
|
||||
return (
|
||||
<div className="term-drop" ref={box} data-action-keydown="layer.dismiss" onKeyDown={(e) => { if (e.key === "Escape" && open) { e.stopPropagation(); close(); } }}>
|
||||
{button(() => { if (!open) onOpen?.(); setOpen(!open); }, open)}
|
||||
{open && <div className="term-menu" role="menu" aria-label={label}>{children(close)}</div>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export interface TerminalHeadProps {
|
||||
shell: string;
|
||||
dir: string;
|
||||
shells: ShellChoice[];
|
||||
onLoadShells: () => void;
|
||||
onShell: (kind: string) => void;
|
||||
onClear: () => void;
|
||||
onReconnect: () => void;
|
||||
onCopyAll: () => void;
|
||||
onSize: (change: "larger" | "smaller" | "reset") => void;
|
||||
}
|
||||
|
||||
// The shell is named here and picked here; choosing another one opens a
|
||||
// terminal in it beside this one, so nothing running is cut off.
|
||||
export function TerminalHead({ shell, dir, shells, onLoadShells, onShell, onClear, onReconnect, onCopyAll, onSize }: TerminalHeadProps) {
|
||||
return (
|
||||
<div className="term-head">
|
||||
<Drop label={t("terminal.pick_a_shell")} onOpen={onLoadShells}
|
||||
button={(toggle, open) => (
|
||||
<button type="button" className="term-shell" data-action="terminal.shell-menu" aria-haspopup="menu" aria-expanded={open} aria-label={t("terminal.shell_picker")} onClick={toggle}>
|
||||
{shell}<StudioIcon name="down" />
|
||||
</button>
|
||||
)}>
|
||||
{(close) => (
|
||||
<>
|
||||
{shells.map((choice) => (
|
||||
<button key={choice.kind} type="button" role="menuitem" data-action="terminal.shell" data-target={choice.kind} data-current={choice.kind === shell ? "" : undefined}
|
||||
onClick={() => { close(); if (choice.kind !== shell) onShell(choice.kind); }}>
|
||||
<span>{choice.name}</span><kbd>{choice.kind}</kbd>{choice.kind === shell && <StudioIcon name="check" />}
|
||||
</button>
|
||||
))}
|
||||
<p>{t("terminal.default_shell_note")}</p>
|
||||
</>
|
||||
)}
|
||||
</Drop>
|
||||
<span className="term-dir" title={dir}>{dir}</span>
|
||||
<span className="term-grow" />
|
||||
<button type="button" className="term-tool" data-action="terminal.clear" aria-label={t("terminal.clear")} title={t("terminal.clear")} onClick={onClear}><StudioIcon name="eraser" /></button>
|
||||
<button type="button" className="term-tool" data-action="terminal.reconnect" aria-label={t("terminal.reconnect")} title={t("terminal.reconnect")} onClick={onReconnect}><StudioIcon name="refresh" /></button>
|
||||
<Drop label={t("terminal.more")}
|
||||
button={(toggle, open) => (
|
||||
<button type="button" className="term-tool" data-action="terminal.more" aria-haspopup="menu" aria-expanded={open} aria-label={t("terminal.more")} title={t("terminal.more")} onClick={toggle}><StudioIcon name="more" /></button>
|
||||
)}>
|
||||
{(close) => (
|
||||
<>
|
||||
<button type="button" role="menuitem" data-action="terminal.copy-all" onClick={() => { close(); onCopyAll(); }}><span>{t("terminal.copy_all")}</span></button>
|
||||
<button type="button" role="menuitem" data-action="terminal.size" data-value="larger" onClick={() => onSize("larger")}><span>{t("terminal.font_larger")}</span></button>
|
||||
<button type="button" role="menuitem" data-action="terminal.size" data-value="smaller" onClick={() => onSize("smaller")}><span>{t("terminal.font_smaller")}</span></button>
|
||||
<button type="button" role="menuitem" data-action="terminal.size" data-value="reset" onClick={() => { close(); onSize("reset"); }}><span>{t("terminal.font_reset")}</span></button>
|
||||
</>
|
||||
)}
|
||||
</Drop>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
import type { AgentPort } from "../port/port";
|
||||
import type { ShellChoice, TerminalInfo } from "../port/terminal";
|
||||
import { TerminalView } from "./TerminalView";
|
||||
|
||||
export interface TerminalLayerProps {
|
||||
port: AgentPort;
|
||||
terminals: TerminalInfo[];
|
||||
active: string;
|
||||
shown: boolean;
|
||||
scheme: "light" | "dark";
|
||||
shells: ShellChoice[];
|
||||
onLoadShells: () => void;
|
||||
onShell: (kind: string) => void;
|
||||
onQuote: (text: string) => void;
|
||||
onOpenPage: (url: string) => void;
|
||||
onCommand: (id: string, line: string) => void;
|
||||
onRestart: (id: string) => Promise<void>;
|
||||
onClose: (id: string) => void;
|
||||
}
|
||||
|
||||
// Every terminal stays mounted, whichever tab is in front, so a shell's screen
|
||||
// is where it was left; only the one in front is read from the kernel.
|
||||
export default function TerminalLayer({ port, terminals, active, shown, scheme, shells, onLoadShells, onShell, onQuote, onOpenPage, onCommand, onRestart, onClose }: TerminalLayerProps) {
|
||||
return (
|
||||
<>
|
||||
{terminals.map((info) => (
|
||||
<TerminalView key={info.id} port={port} info={info} shown={shown && info.id === active} scheme={scheme} shells={shells}
|
||||
onLoadShells={onLoadShells} onShell={onShell} onQuote={onQuote} onOpenPage={onOpenPage}
|
||||
onCommand={(line) => onCommand(info.id, line)} onRestart={() => onRestart(info.id)} onClose={() => onClose(info.id)} />
|
||||
))}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,167 @@
|
||||
// @vitest-environment jsdom
|
||||
import { afterEach, beforeEach, expect, it, vi } from "vitest";
|
||||
import { act, cleanup, render, screen, waitFor } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import "./testkit";
|
||||
|
||||
// The emulator draws to a canvas jsdom does not have; what is under test is how
|
||||
// the view feeds it and what it does with what the person does to it.
|
||||
const fake = vi.hoisted(() => {
|
||||
const screens: FakeTerm[] = [];
|
||||
class FakeTerm {
|
||||
cols = 100;
|
||||
rows = 30;
|
||||
options: Record<string, unknown> = {};
|
||||
written: Uint8Array[] = [];
|
||||
resets = 0;
|
||||
selection = "";
|
||||
data: ((d: string) => void) | null = null;
|
||||
keys: ((e: KeyboardEvent) => boolean) | null = null;
|
||||
buffer = { active: { length: 2, getLine: (i: number) => ({ translateToString: () => ["first line", "second line"][i] }) } };
|
||||
constructor() { screens.push(this); }
|
||||
loadAddon() {}
|
||||
open() {}
|
||||
focus() {}
|
||||
dispose() {}
|
||||
clear() {}
|
||||
reset() { this.resets += 1; this.written = []; }
|
||||
write(b: Uint8Array) { this.written.push(b); }
|
||||
onData(f: (d: string) => void) { this.data = f; }
|
||||
onResize() {}
|
||||
onSelectionChange() {}
|
||||
attachCustomKeyEventHandler(f: (e: KeyboardEvent) => boolean) { this.keys = f; }
|
||||
hasSelection() { return this.selection !== ""; }
|
||||
getSelection() { return this.selection; }
|
||||
clearSelection() { this.selection = ""; }
|
||||
text() { return this.written.map((b) => new TextDecoder().decode(b)).join(""); }
|
||||
}
|
||||
const link: { open: ((e: MouseEvent, url: string) => void) | null } = { open: null };
|
||||
return { screens, FakeTerm, link };
|
||||
});
|
||||
const screens = fake.screens;
|
||||
vi.mock("@xterm/xterm", () => ({ Terminal: fake.FakeTerm }));
|
||||
vi.mock("@xterm/xterm/css/xterm.css", () => ({}));
|
||||
vi.mock("@xterm/addon-fit", () => ({ FitAddon: class { fit() {} } }));
|
||||
vi.mock("@xterm/addon-web-links", () => ({ WebLinksAddon: class { constructor(open: (e: MouseEvent, url: string) => void) { fake.link.open = open; } } }));
|
||||
globalThis.ResizeObserver ??= class { observe() {} disconnect() {} unobserve() {} } as unknown as typeof ResizeObserver;
|
||||
|
||||
import { MockPort } from "../port/mock";
|
||||
import type { TerminalInfo } from "../port/terminal";
|
||||
import { TerminalView, type TerminalViewProps } from "./TerminalView";
|
||||
|
||||
beforeEach(() => { localStorage.clear(); screens.length = 0; fake.link.open = null; });
|
||||
afterEach(cleanup);
|
||||
|
||||
async function setup(over: Partial<TerminalViewProps> = {}) {
|
||||
const port = new MockPort();
|
||||
const info: TerminalInfo = await port.openTerminal("bash", 80, 24);
|
||||
const props: TerminalViewProps = {
|
||||
port, info, shown: true, scheme: "light", shells: [], onLoadShells: () => {}, onShell: () => {}, onQuote: () => {},
|
||||
onOpenPage: () => {}, onCommand: () => {}, onRestart: async () => { await port.restartTerminal(info.id); }, onClose: () => {}, ...over,
|
||||
};
|
||||
const view = render(<TerminalView {...props} />);
|
||||
await waitFor(() => expect(screens[0]?.text()).toContain("reasonix"));
|
||||
return { port, info, props, view, term: () => screens[0] };
|
||||
}
|
||||
|
||||
it("draws what the shell already wrote when the tab comes to the front", async () => {
|
||||
const { term } = await setup();
|
||||
expect(term().resets).toBe(1);
|
||||
expect(term().text()).toContain("(main)");
|
||||
});
|
||||
|
||||
it("sends each keystroke to the shell and names the line when it is submitted", async () => {
|
||||
const typed: string[] = [];
|
||||
const commands: string[] = [];
|
||||
const { port, info, term } = await setup({ onCommand: (line) => commands.push(line) });
|
||||
const input = vi.spyOn(port, "terminalInput");
|
||||
act(() => { for (const ch of "ls") term().data!(ch); term().data!("\r"); });
|
||||
expect(input).toHaveBeenCalledWith(info.id, "l");
|
||||
expect(commands).toEqual(["ls"]);
|
||||
expect(typed).toEqual([]);
|
||||
await waitFor(() => expect(term().text()).toContain("ls: command not found"));
|
||||
});
|
||||
|
||||
it("does not read the shell while another tab is in front, and reads it again from the start on return", async () => {
|
||||
const { props, view, term } = await setup();
|
||||
const reading = () => (props.port as unknown as { fakes: Map<string, { readers: Set<unknown> }> }).fakes.get(props.info.id)?.readers.size;
|
||||
expect(reading()).toBe(1);
|
||||
view.rerender(<TerminalView {...props} shown={false} />);
|
||||
expect(reading()).toBe(0);
|
||||
view.rerender(<TerminalView {...props} shown />);
|
||||
await waitFor(() => expect(term().resets).toBe(2));
|
||||
expect(term().text()).toContain("(main)");
|
||||
});
|
||||
|
||||
it("says the process exited with its code and offers to start it again or close the tab", async () => {
|
||||
const closed = vi.fn();
|
||||
const { port, info, term } = await setup({ onClose: closed });
|
||||
act(() => { for (const ch of "exit") term().data!(ch); term().data!("\r"); });
|
||||
expect(await screen.findByText("进程已退出(代码 130)")).toBeTruthy();
|
||||
await userEvent.click(screen.getByRole("button", { name: "重新启动" }));
|
||||
await waitFor(() => expect(screen.queryByText(/进程已退出/)).toBeNull());
|
||||
expect((await port.terminals()).find((x) => x.id === info.id)?.running).toBe(true);
|
||||
act(() => { for (const ch of "exit") term().data!(ch); term().data!("\r"); });
|
||||
await userEvent.click(await screen.findByRole("button", { name: "关闭页签" }));
|
||||
expect(closed).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("says what the terminal is on first opening and not again once understood", async () => {
|
||||
const { view, props } = await setup();
|
||||
expect(screen.getByRole("note").textContent).toContain("不经过模型的权限和沙盒");
|
||||
await userEvent.click(screen.getByRole("button", { name: "知道了" }));
|
||||
expect(screen.queryByRole("note")).toBeNull();
|
||||
view.unmount();
|
||||
render(<TerminalView {...props} />);
|
||||
expect(screen.queryByRole("note")).toBeNull();
|
||||
});
|
||||
|
||||
it("offers a selection to the conversation only when the person picked it, and copies it as asked", async () => {
|
||||
const quoted = vi.fn();
|
||||
const { term } = await setup({ onQuote: quoted });
|
||||
const body = document.querySelector<HTMLElement>(".term-body")!;
|
||||
await userEvent.click(body);
|
||||
expect(screen.queryByRole("menuitem", { name: "发给对话" })).toBeNull();
|
||||
term().selection = "FAIL: TestRetryAfter";
|
||||
act(() => { body.dispatchEvent(new MouseEvent("mouseup", { bubbles: true, clientX: 40, clientY: 40 })); });
|
||||
await userEvent.click(await screen.findByRole("menuitem", { name: "发给对话" }));
|
||||
expect(quoted).toHaveBeenCalledWith("FAIL: TestRetryAfter");
|
||||
expect(screen.queryByRole("menuitem", { name: "发给对话" })).toBeNull();
|
||||
});
|
||||
|
||||
it("opens an address from the output in the built-in browser or copies it, on the person's choice", async () => {
|
||||
const page = vi.fn();
|
||||
await setup({ onOpenPage: page });
|
||||
act(() => fake.link.open!(new MouseEvent("click", { clientX: 10, clientY: 10 }), "http://localhost:5173/"));
|
||||
await userEvent.click(await screen.findByRole("menuitem", { name: "在内置浏览器打开" }));
|
||||
expect(page).toHaveBeenCalledWith("http://localhost:5173/");
|
||||
});
|
||||
|
||||
it("copies the selection on Ctrl+C instead of interrupting the shell, and sends the interrupt otherwise", async () => {
|
||||
const write = vi.fn(async () => {});
|
||||
Object.defineProperty(navigator, "clipboard", { value: { writeText: write }, configurable: true });
|
||||
const { term } = await setup();
|
||||
term().selection = "picked";
|
||||
const press = () => term().keys!(new KeyboardEvent("keydown", { key: "c", ctrlKey: true }));
|
||||
expect(press()).toBe(false);
|
||||
expect(write).toHaveBeenCalledWith("picked");
|
||||
term().selection = "";
|
||||
expect(press()).toBe(true);
|
||||
});
|
||||
|
||||
it("copies everything the screen holds from the more menu", async () => {
|
||||
const write = vi.fn(async () => {});
|
||||
Object.defineProperty(navigator, "clipboard", { value: { writeText: write }, configurable: true });
|
||||
await setup();
|
||||
await userEvent.click(screen.getByRole("button", { name: "更多:复制全部、字体大小" }));
|
||||
await userEvent.click(screen.getByRole("menuitem", { name: "复制全部" }));
|
||||
expect(write).toHaveBeenCalledWith("first line\nsecond line");
|
||||
});
|
||||
|
||||
it("opens a terminal in the picked shell beside this one, and does nothing for the shell it already is", async () => {
|
||||
const shell = vi.fn();
|
||||
await setup({ onShell: shell, shells: [{ name: "Git Bash", kind: "bash", path: "/usr/bin/bash" }, { name: "PowerShell", kind: "pwsh", path: "/usr/bin/pwsh" }] });
|
||||
await userEvent.click(screen.getByRole("button", { name: "终端程序" }));
|
||||
await userEvent.click(screen.getByRole("menuitem", { name: /PowerShell/ }));
|
||||
expect(shell).toHaveBeenCalledWith("pwsh");
|
||||
});
|
||||
@@ -0,0 +1,247 @@
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { Terminal } from "@xterm/xterm";
|
||||
import { FitAddon } from "@xterm/addon-fit";
|
||||
import { WebLinksAddon } from "@xterm/addon-web-links";
|
||||
import "@xterm/xterm/css/xterm.css";
|
||||
import { t } from "../i18n";
|
||||
import { reason } from "../i18n/kernel";
|
||||
import type { AgentPort } from "../port/port";
|
||||
import type { ShellChoice, TerminalInfo, TerminalStream } from "../port/terminal";
|
||||
import { monoFamily, terminalTheme } from "./terminal_theme";
|
||||
import { noteSeen, seeNote, setTerminalSize, terminalSize, TERMINAL_SIZES } from "./terminal_prefs";
|
||||
import { StudioIcon } from "./StudioIcon";
|
||||
import { TerminalHead } from "./TerminalHead";
|
||||
|
||||
const bytes = (b64: string) => Uint8Array.from(atob(b64), (c) => c.charCodeAt(0));
|
||||
const RECONNECT_MS = 1000;
|
||||
const RECONNECTS = 5;
|
||||
|
||||
export interface TerminalViewProps {
|
||||
port: AgentPort;
|
||||
info: TerminalInfo;
|
||||
shown: boolean;
|
||||
scheme: "light" | "dark";
|
||||
shells: ShellChoice[];
|
||||
onLoadShells: () => void;
|
||||
onShell: (kind: string) => void;
|
||||
onQuote: (text: string) => void;
|
||||
onOpenPage: (url: string) => void;
|
||||
onCommand: (line: string) => void;
|
||||
onRestart: () => Promise<void>;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
interface Float { x: number; y: number; text: string; url?: string }
|
||||
|
||||
// One shell's screen. It stays mounted while another tab is in front, so what
|
||||
// was on it is still there; the kernel's own copy is what a reader that left
|
||||
// comes back to.
|
||||
export function TerminalView({ port, info, shown, scheme, shells, onLoadShells, onShell, onQuote, onOpenPage, onCommand, onRestart, onClose }: TerminalViewProps) {
|
||||
const body = useRef<HTMLDivElement>(null);
|
||||
const screen = useRef<HTMLDivElement>(null);
|
||||
const term = useRef<Terminal | null>(null);
|
||||
const fit = useRef<FitAddon | null>(null);
|
||||
const stream = useRef<TerminalStream | null>(null);
|
||||
const watch = useRef<ResizeObserver | null>(null);
|
||||
const typed = useRef("");
|
||||
const [exit, setExit] = useState<number | null>(null);
|
||||
const [note, setNote] = useState(() => !noteSeen());
|
||||
const [float, setFloat] = useState<Float | null>(null);
|
||||
const [failure, setFailure] = useState("");
|
||||
const [size, setSize] = useState(terminalSize);
|
||||
const [round, setRound] = useState(0);
|
||||
const latest = useRef({ onCommand, onOpenPage });
|
||||
latest.current = { onCommand, onOpenPage };
|
||||
|
||||
const ended = exit ?? (info.running ? null : info.exitCode ?? 0);
|
||||
|
||||
const dropFloat = useCallback(() => setFloat(null), []);
|
||||
|
||||
// The screen is made the first time the tab is in front: it cannot be fitted
|
||||
// while hidden, and a terminal made at the wrong size repaints on the first resize.
|
||||
useEffect(() => {
|
||||
if (!shown || term.current || !screen.current) return;
|
||||
const host = screen.current;
|
||||
const xterm = new Terminal({
|
||||
fontFamily: monoFamily(host), fontSize: terminalSize(), lineHeight: 1.55, cursorBlink: true, scrollback: 5000,
|
||||
theme: terminalTheme(host), allowProposedApi: false,
|
||||
});
|
||||
const fitter = new FitAddon();
|
||||
xterm.loadAddon(fitter);
|
||||
xterm.loadAddon(new WebLinksAddon((event, url) => {
|
||||
const at = body.current?.getBoundingClientRect();
|
||||
setFloat({ x: event.clientX - (at?.left ?? 0), y: event.clientY - (at?.top ?? 0), text: url, url });
|
||||
}));
|
||||
xterm.open(host);
|
||||
term.current = xterm;
|
||||
fit.current = fitter;
|
||||
fitter.fit();
|
||||
void port.resizeTerminal(info.id, xterm.cols, xterm.rows).catch(() => {});
|
||||
xterm.onData((data) => {
|
||||
void port.terminalInput(info.id, data).catch(() => {});
|
||||
if (data.startsWith("\x1b")) return;
|
||||
for (const ch of data) {
|
||||
if (ch === "\r") {
|
||||
const line = typed.current.trim();
|
||||
typed.current = "";
|
||||
if (line) latest.current.onCommand(line);
|
||||
} else if (ch === "\x7f") typed.current = typed.current.slice(0, -1);
|
||||
else if (ch >= " ") typed.current += ch;
|
||||
}
|
||||
});
|
||||
xterm.onResize(({ cols, rows }) => void port.resizeTerminal(info.id, cols, rows).catch(() => {}));
|
||||
xterm.onSelectionChange(() => { if (!xterm.hasSelection()) setFloat((f) => (f?.url ? f : null)); });
|
||||
xterm.attachCustomKeyEventHandler((e) => {
|
||||
if (e.type === "keydown" && (e.ctrlKey || e.metaKey) && e.key.toLowerCase() === "c" && xterm.hasSelection()) {
|
||||
void navigator.clipboard.writeText(xterm.getSelection());
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
});
|
||||
watch.current = new ResizeObserver(() => { if (host.offsetParent) fitter.fit(); });
|
||||
watch.current.observe(host);
|
||||
}, [shown, port, info.id]);
|
||||
|
||||
useEffect(() => () => {
|
||||
stream.current?.close();
|
||||
watch.current?.disconnect();
|
||||
term.current?.dispose();
|
||||
term.current = null;
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
const xterm = term.current;
|
||||
if (!xterm || !screen.current) return;
|
||||
xterm.options.theme = terminalTheme(screen.current);
|
||||
}, [scheme]);
|
||||
|
||||
useEffect(() => {
|
||||
const xterm = term.current;
|
||||
if (!xterm) return;
|
||||
xterm.options.fontSize = size;
|
||||
fit.current?.fit();
|
||||
}, [size, shown]);
|
||||
|
||||
// Reading starts when the tab is in front and stops when it is not, so a
|
||||
// closed-over tab holds no connection; coming back reads the screen again from
|
||||
// what the kernel kept, which is why nothing is lost by leaving.
|
||||
useEffect(() => {
|
||||
const xterm = term.current;
|
||||
if (!shown || !xterm) return;
|
||||
let live = true;
|
||||
let attempts = 0;
|
||||
let timer = 0;
|
||||
const open = () => {
|
||||
stream.current = port.streamTerminal(
|
||||
info.id,
|
||||
(frame) => {
|
||||
if (!live) return;
|
||||
if (frame.kind === "replay") { xterm.reset(); attempts = 0; if (frame.data) xterm.write(bytes(frame.data)); }
|
||||
else if (frame.kind === "out" && frame.data) xterm.write(bytes(frame.data));
|
||||
else if (frame.kind === "exit") { attempts = RECONNECTS; setExit(frame.code ?? 0); }
|
||||
},
|
||||
() => {
|
||||
if (!live || attempts >= RECONNECTS) return;
|
||||
attempts += 1;
|
||||
timer = window.setTimeout(() => { if (live) open(); }, RECONNECT_MS);
|
||||
},
|
||||
);
|
||||
};
|
||||
open();
|
||||
xterm.focus();
|
||||
return () => {
|
||||
live = false;
|
||||
window.clearTimeout(timer);
|
||||
stream.current?.close();
|
||||
stream.current = null;
|
||||
};
|
||||
}, [shown, port, info.id, round]);
|
||||
|
||||
const restart = async () => {
|
||||
setFailure("");
|
||||
try {
|
||||
await onRestart();
|
||||
setExit(null);
|
||||
setRound((n) => n + 1);
|
||||
} catch (e) {
|
||||
setFailure(reason(e));
|
||||
}
|
||||
};
|
||||
|
||||
const copyAll = () => {
|
||||
const buffer = term.current?.buffer.active;
|
||||
if (!buffer) return;
|
||||
const lines: string[] = [];
|
||||
for (let i = 0; i < buffer.length; i++) lines.push(buffer.getLine(i)?.translateToString(true) ?? "");
|
||||
void navigator.clipboard.writeText(lines.join("\n").replace(/\n+$/, ""));
|
||||
};
|
||||
|
||||
const changeSize = (change: "larger" | "smaller" | "reset") => {
|
||||
const next = change === "reset" ? TERMINAL_SIZES.standard : size + (change === "larger" ? 1 : -1);
|
||||
setTerminalSize(next);
|
||||
setSize(terminalSize());
|
||||
};
|
||||
|
||||
const pointed = (e: { clientX: number; clientY: number }, text: string) => {
|
||||
const at = body.current?.getBoundingClientRect();
|
||||
setFloat({ x: e.clientX - (at?.left ?? 0), y: e.clientY - (at?.top ?? 0), text });
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="term" hidden={!shown} data-scheme={scheme} aria-label={t("terminal.name")}>
|
||||
{note && (
|
||||
<div className="term-note" role="note">
|
||||
<StudioIcon name="shield" />
|
||||
<p>{t("terminal.not_the_models")}</p>
|
||||
<button type="button" data-action="terminal.note-dismiss" onClick={() => { seeNote(); setNote(false); }}>{t("common.got_it")}</button>
|
||||
</div>
|
||||
)}
|
||||
<TerminalHead
|
||||
shell={shells.find((s) => s.path === info.shell)?.kind ?? info.name} dir={info.dir} shells={shells} onLoadShells={onLoadShells} onShell={onShell}
|
||||
onClear={() => term.current?.clear()} onReconnect={() => setRound((n) => n + 1)} onCopyAll={copyAll} onSize={changeSize}
|
||||
/>
|
||||
<div className="term-body" ref={body}
|
||||
data-action-mouseup="terminal.select" data-action-mousedown="layer.dismiss" data-action-keydown="layer.dismiss"
|
||||
onMouseUp={(e) => {
|
||||
if ((e.target as HTMLElement).closest(".term-float")) return;
|
||||
const text = term.current?.hasSelection() ? term.current.getSelection() : "";
|
||||
if (text.trim()) pointed(e, text);
|
||||
}}
|
||||
onMouseDown={(e) => { if (float && !(e.target as HTMLElement).closest(".term-float")) setFloat(null); }}
|
||||
onKeyDown={dropFloat}>
|
||||
<div className="term-screen" ref={screen} role="log" aria-label={t("terminal.the_terminal")} />
|
||||
{float && (
|
||||
<div className="term-float" role="menu" style={{ left: float.x, top: float.y }}>
|
||||
{float.url ? (
|
||||
<>
|
||||
<button type="button" role="menuitem" data-action="terminal.open-link" onClick={() => { latest.current.onOpenPage(float.url!); setFloat(null); }}>
|
||||
<StudioIcon name="globe" />{t("terminal.open_in_browser")}
|
||||
</button>
|
||||
<button type="button" role="menuitem" data-action="terminal.copy-link" onClick={() => { void navigator.clipboard.writeText(float.url!); setFloat(null); }}>
|
||||
<StudioIcon name="copy" />{t("terminal.copy_link")}
|
||||
</button>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<button type="button" role="menuitem" data-action="terminal.send-to-chat" onClick={() => { onQuote(float.text); term.current?.clearSelection(); setFloat(null); }}>
|
||||
<StudioIcon name="quote" />{t("terminal.send_to_chat")}
|
||||
</button>
|
||||
<button type="button" role="menuitem" data-action="terminal.copy-selection" onClick={() => { void navigator.clipboard.writeText(float.text); term.current?.clearSelection(); setFloat(null); }}>
|
||||
<StudioIcon name="copy" />{t("common.copy")}
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{failure && <div className="workbench-error" role="alert">{failure}</div>}
|
||||
{ended !== null && (
|
||||
<div className="term-exit" role="status">
|
||||
<span>{t("terminal.exited", { code: ended })}</span>
|
||||
<button type="button" data-primary="" data-action="terminal.restart" onClick={() => void restart()}>{t("terminal.restart")}</button>
|
||||
<button type="button" data-action="terminal.close" onClick={onClose}>{t("workbench.close_tab")}</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -2,7 +2,7 @@ import { t } from "../i18n";
|
||||
import { StudioIcon } from "./StudioIcon";
|
||||
|
||||
/** A new tab: the three things the workbench opens, then what was opened lately. */
|
||||
export function WorkbenchLauncher({ changes, focus = false, onFiles, onFind, onBrowser, recent = [], onOpenFile }: { changes: number; focus?: boolean; onFiles: () => void; onFind: () => void; onBrowser: () => void; recent?: string[]; onOpenFile?: (path: string) => void }) {
|
||||
export function WorkbenchLauncher({ changes, focus = false, onFiles, onFind, onBrowser, onTerminal, recent = [], onOpenFile }: { changes: number; focus?: boolean; onFiles: () => void; onFind: () => void; onBrowser: () => void; onTerminal?: () => void; recent?: string[]; onOpenFile?: (path: string) => void }) {
|
||||
return (
|
||||
<div className="workbench-launcher" role="group" aria-label={t("common.start")}>
|
||||
<p className="workbench-launcher-title">{t("workbench.open_in_the_workbench")}</p>
|
||||
@@ -18,6 +18,12 @@ export function WorkbenchLauncher({ changes, focus = false, onFiles, onFind, onB
|
||||
<StudioIcon name="globe" />
|
||||
<span><b>{t("workbench.browser")}</b><i>{t("workbench.view_web_pages_beside_the")}</i></span>
|
||||
</button>
|
||||
{onTerminal && (
|
||||
<button aria-label={t("terminal.name")} data-action="workbench.launch-terminal" onClick={onTerminal}>
|
||||
<StudioIcon name="terminal" />
|
||||
<span><b>{t("terminal.name")}</b><i>{t("terminal.launch_hint")}</i></span>
|
||||
</button>
|
||||
)}
|
||||
{recent.length > 0 && onOpenFile && (
|
||||
<div className="workbench-launcher-recent" role="group" aria-label={t("workbench.recently_opened")}>
|
||||
<p>{t("workbench.recently_opened")}</p>
|
||||
|
||||
@@ -19,7 +19,11 @@ import { leave, slideAway, useEnteringTabs, useOpenSlide, useOverflowTabs, useSw
|
||||
import { WorkbenchTabOverflow } from "./WorkbenchTabOverflow";
|
||||
import { WorkbenchCloseAsk } from "./WorkbenchCloseAsk";
|
||||
import { WorkbenchState } from "./WorkbenchState";
|
||||
import { AgentBrowserPanel, ManualBrowserPanel, UNREAD_TABS } from "./BrowserPanel";
|
||||
import { AgentBrowserPanel, UNREAD_TABS } from "./BrowserPanel";
|
||||
import { ManualBrowsers } from "./ManualBrowsers";
|
||||
import { WorkbenchTabIcon } from "./WorkbenchTabIcon";
|
||||
import { WorkbenchTerminals } from "./WorkbenchTerminals";
|
||||
import { useTerminalTabs } from "./useTerminalTabs";
|
||||
import { useOnBump } from "./useOnBump";
|
||||
import { StudioIcon } from "./StudioIcon";
|
||||
import { LazyMarkdown } from "./LazyMarkdown";
|
||||
@@ -53,9 +57,7 @@ const CodeEditor = lazy(() => import("./CodeEditor"));
|
||||
import { treeRows } from "./workbench_tree";
|
||||
import { AgentRunView } from "./AgentRunView";
|
||||
import { JobRunView } from "./JobRunView";
|
||||
import { AgentAvatar } from "./cards/SubagentGroup";
|
||||
import { useAgentTabs, type AgentRequest } from "./useAgentTabs";
|
||||
import { fileKind, langOf } from "./fileKind";
|
||||
import { readRecentFiles, rememberFile } from "./recentFiles";
|
||||
|
||||
export function WorkbenchPanel({
|
||||
@@ -86,8 +88,11 @@ export function WorkbenchPanel({
|
||||
fileRequest,
|
||||
agentRequest,
|
||||
onShowCall,
|
||||
terminalRequest = 0,
|
||||
}: {
|
||||
onShowCall?: (callId: string) => void;
|
||||
// Counts the presses that ask for a terminal.
|
||||
terminalRequest?: number;
|
||||
port: AgentPort;
|
||||
tabs: BrowserTab[];
|
||||
manual: boolean;
|
||||
@@ -176,6 +181,7 @@ export function WorkbenchPanel({
|
||||
const [mode, setMode] = useState<FileMode>("file");
|
||||
const { renaming, setRenaming, labelFor, nameTab } = useTabNames((surface) => labelOf(surface, hosts));
|
||||
const [failed, setFailed] = useState("");
|
||||
const terminals = useTerminalTabs({ port, shown, pick, setDismissed, onExternal, onError: setFailed, request: terminalRequest });
|
||||
// Showing a file where it lives needs a shell on the kernel's own machine.
|
||||
const revealable = !remote && port.revealsFiles();
|
||||
const [platform, setPlatform] = useState("");
|
||||
@@ -312,8 +318,9 @@ export function WorkbenchPanel({
|
||||
...tabs.map((tab) => ({ kind: "browser", tab }) as const),
|
||||
...openFiles.map((path) => ({ kind: "file", path }) as const),
|
||||
...agents,
|
||||
...terminals.tabs,
|
||||
].filter((s) => !dismissed.has(keyOf(s))),
|
||||
[changesOpen, browsers, tabs, openFiles, agents, dismissed],
|
||||
[changesOpen, browsers, tabs, openFiles, agents, terminals.tabs, dismissed],
|
||||
);
|
||||
const active =
|
||||
surfaces.find((s) => keyOf(s) === selected) ??
|
||||
@@ -450,7 +457,7 @@ export function WorkbenchPanel({
|
||||
}
|
||||
};
|
||||
const { close: closeTabs, closing, closeFailure } = useTabClose({ port, surfaces, selected, showFiles: showFiles || launcher, fromExplorer, setBrowsers, setOpenFiles, setAgents, setDismissed, setSelected, setShowFiles, setLauncher, onCloseManual, onCloseSelected: () => setFailed("") });
|
||||
const close = (targets: Surface[]) => { supersede(); return closeTabs(targets); };
|
||||
const close = (targets: Surface[]) => { supersede(); return terminals.guard(targets, closeTabs); };
|
||||
const dismissTabMenu = useCallback(() => setTabMenu(null), []);
|
||||
const [tabMenu, setTabMenu] = useState<{ surface: Surface; x: number; y: number; anchor: HTMLElement } | null>(null);
|
||||
const pages = surfaces.filter((s) => s.kind !== "file");
|
||||
@@ -550,8 +557,9 @@ export function WorkbenchPanel({
|
||||
else if (e.key === "F2" && surface.kind === "browser") { e.preventDefault(); setRenaming(keyOf(surface)); }
|
||||
}}
|
||||
>
|
||||
{surface.kind === "agent" ? <AgentAvatar name={surface.name} /> : <StudioIcon name={surface.kind === "file" ? "file" : surface.kind === "job" ? "play" : surface.kind === "changes" ? "branch" : "globe"} data-kind={surface.kind === "file" ? fileKind(surface.path) || undefined : undefined} data-lang={surface.kind === "file" ? langOf(surface.path) : undefined} />}
|
||||
<WorkbenchTabIcon surface={surface} />
|
||||
<span>{labelFor(surface)}</span>
|
||||
{surface.kind === "terminal" && surface.busy && <i className="workbench-tab-busy" aria-hidden />}
|
||||
{surface.kind === "changes" && changes.length > 0 && <small className="workbench-tab-count">{changes.length}</small>}
|
||||
</button>
|
||||
)}
|
||||
@@ -612,21 +620,14 @@ export function WorkbenchPanel({
|
||||
changes={changes.length}
|
||||
onFiles={chooseFiles}
|
||||
onFind={() => { chooseFiles(); requestAnimationFrame(() => document.querySelector<HTMLInputElement>(".workbench-search input")?.focus()); }}
|
||||
onBrowser={() => void addBrowser()}
|
||||
onBrowser={() => void addBrowser()} onTerminal={() => void terminals.open()}
|
||||
recent={recentFiles} onOpenFile={openFile}
|
||||
/>
|
||||
)}
|
||||
<div className="workbench-content" ref={canvas} hidden={launcher || !active} data-action-contextmenu="workbench.quote-selection" {...textMenu.readerProps}>
|
||||
{browsers.map((id) => (
|
||||
<ManualBrowserPanel
|
||||
key={id}
|
||||
id={id}
|
||||
hidden={!(active?.kind === "manual" && active.id === id)}
|
||||
onAddress={noteHost}
|
||||
onExternal={onExternal}
|
||||
scheme={scheme}
|
||||
/>
|
||||
))}
|
||||
<ManualBrowsers ids={browsers} active={active?.kind === "manual" ? active.id : ""} onAddress={noteHost} onExternal={onExternal} scheme={scheme} />
|
||||
<WorkbenchTerminals port={port} hook={terminals} active={active?.kind === "terminal" ? active.id : ""} shown={shown && !launcher && !showFiles}
|
||||
scheme={scheme} onQuote={onQuoteSelection} onClose={(targets) => void close(targets)} />
|
||||
{active?.kind === "browser" && (
|
||||
<AgentBrowserPanel
|
||||
tabs={[active.tab]}
|
||||
|
||||
@@ -0,0 +1,12 @@
|
||||
import { AgentAvatar } from "./cards/SubagentGroup";
|
||||
import { fileKind, langOf } from "./fileKind";
|
||||
import { StudioIcon } from "./StudioIcon";
|
||||
import type { Surface } from "./workbench_tabs";
|
||||
|
||||
const ICONS = { file: "file", job: "play", changes: "branch", terminal: "terminal" } as const;
|
||||
|
||||
export function WorkbenchTabIcon({ surface }: { surface: Surface }) {
|
||||
if (surface.kind === "agent") return <AgentAvatar name={surface.name} />;
|
||||
const file = surface.kind === "file";
|
||||
return <StudioIcon name={ICONS[surface.kind as keyof typeof ICONS] ?? "globe"} data-kind={file ? fileKind(surface.path) || undefined : undefined} data-lang={file ? langOf(surface.path) : undefined} />;
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
import { lazy, Suspense } from "react";
|
||||
import { t } from "../i18n";
|
||||
import type { AgentPort } from "../port/port";
|
||||
import { TerminalCloseAsk } from "./TerminalCloseAsk";
|
||||
import type { useTerminalTabs } from "./useTerminalTabs";
|
||||
import type { Surface } from "./workbench_tabs";
|
||||
|
||||
const TerminalLayer = lazy(() => import("./TerminalLayer"));
|
||||
|
||||
// The screens are loaded with the first terminal, not with the workbench: the
|
||||
// emulator is most of what a terminal costs.
|
||||
export function WorkbenchTerminals({ port, hook, active, shown, scheme, onQuote, onClose }: {
|
||||
port: AgentPort;
|
||||
hook: ReturnType<typeof useTerminalTabs>;
|
||||
active: string;
|
||||
shown: boolean;
|
||||
scheme: "light" | "dark";
|
||||
onQuote?: (text: string, source: string) => void;
|
||||
onClose: (targets: Surface[]) => void;
|
||||
}) {
|
||||
const named = (id: string) => hook.tabs.find((tab) => tab.id === id);
|
||||
return (
|
||||
<>
|
||||
{hook.terminals.length > 0 && (
|
||||
<Suspense fallback={null}>
|
||||
<TerminalLayer port={port} terminals={hook.terminals} active={active} shown={shown} scheme={scheme} shells={hook.shells}
|
||||
onLoadShells={hook.loadShells} onShell={(kind) => void hook.open(kind)} onQuote={(text) => onQuote?.(text, t("terminal.name"))}
|
||||
onOpenPage={(url) => void hook.openUrl(url)} onCommand={hook.submitted} onRestart={hook.restart}
|
||||
onClose={(id) => { const tab = named(id); if (tab) onClose([tab]); }} />
|
||||
</Suspense>
|
||||
)}
|
||||
{hook.asking && <TerminalCloseAsk anchor={hook.asking.anchor} onCancel={hook.cancelAsk} onConfirm={hook.confirmAsk} />}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -56,6 +56,7 @@ export interface PaneProps {
|
||||
findPulse: number;
|
||||
findTogglePulse?: number;
|
||||
filesPulse?: number;
|
||||
terminalPulse?: number;
|
||||
viewRequest?: { to: PaneView; n: number };
|
||||
sessionRef?: { n: number; id: string; title: string };
|
||||
onSettings: (section?: string) => void;
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
// What this machine remembers about the terminal: it presents and starts shells
|
||||
// here, so none of it is a fact about the session.
|
||||
|
||||
const NOTE = "rx-terminal-note";
|
||||
const SHELL = "rx-terminal-shell";
|
||||
const SIZE = "rx-terminal-size";
|
||||
|
||||
export const TERMINAL_SIZES = { min: 9, standard: 12, max: 22 } as const;
|
||||
|
||||
function read(key: string): string {
|
||||
try {
|
||||
return localStorage.getItem(key) ?? "";
|
||||
} catch {
|
||||
return "";
|
||||
}
|
||||
}
|
||||
|
||||
function write(key: string, value: string) {
|
||||
try {
|
||||
localStorage.setItem(key, value);
|
||||
} catch {
|
||||
// A private window keeps the defaults, which is the answer it gives anyway.
|
||||
}
|
||||
}
|
||||
|
||||
export const noteSeen = () => read(NOTE) === "seen";
|
||||
export const seeNote = () => write(NOTE, "seen");
|
||||
|
||||
export const lastShell = () => read(SHELL);
|
||||
export const rememberShell = (kind: string) => write(SHELL, kind);
|
||||
|
||||
export function terminalSize(): number {
|
||||
const n = Number(read(SIZE));
|
||||
return Number.isFinite(n) && n >= TERMINAL_SIZES.min && n <= TERMINAL_SIZES.max ? n : TERMINAL_SIZES.standard;
|
||||
}
|
||||
|
||||
export const setTerminalSize = (n: number) => write(SIZE, String(Math.min(TERMINAL_SIZES.max, Math.max(TERMINAL_SIZES.min, Math.round(n)))));
|
||||
@@ -0,0 +1,28 @@
|
||||
import type { ITheme } from "@xterm/xterm";
|
||||
|
||||
// The terminal is drawn in the page's own colours, read where it sits so a
|
||||
// theme change is one read away rather than a second palette to keep.
|
||||
export function terminalTheme(from: HTMLElement): ITheme {
|
||||
const css = getComputedStyle(from);
|
||||
const get = (name: string, fallback: string) => css.getPropertyValue(name).trim() || fallback;
|
||||
const text = get("--text", "#292b29");
|
||||
const green = get("--ok-ink", "#2f7d4f");
|
||||
const red = get("--err-ink", "#b3402f");
|
||||
const amber = get("--warn-ink", "#8a6a1f");
|
||||
const blue = get("--net-ink", "#2f6f95");
|
||||
const muted = get("--muted", "#5e625c");
|
||||
return {
|
||||
background: get("--raised", "#ffffff"),
|
||||
foreground: text,
|
||||
cursor: text,
|
||||
cursorAccent: get("--raised", "#ffffff"),
|
||||
selectionBackground: get("--net-wash", "#e4e8f4"),
|
||||
selectionInactiveBackground: get("--overlay", "#e9e9e5"),
|
||||
black: text, red, green, yellow: amber, blue, magenta: blue, cyan: blue, white: muted,
|
||||
brightBlack: muted, brightRed: red, brightGreen: green, brightYellow: amber, brightBlue: blue, brightMagenta: blue, brightCyan: blue, brightWhite: text,
|
||||
};
|
||||
}
|
||||
|
||||
export function monoFamily(from: HTMLElement): string {
|
||||
return getComputedStyle(from).getPropertyValue("--mono").trim() || "ui-monospace, Menlo, Consolas, monospace";
|
||||
}
|
||||
@@ -29,6 +29,7 @@ export function useTabClose({ port, surfaces, selected, showFiles, setBrowsers,
|
||||
for (const surface of targets) {
|
||||
try {
|
||||
if (surface.kind === "browser") await port.browserClose(surface.tab.id);
|
||||
else if (surface.kind === "terminal") await port.closeTerminal(surface.id);
|
||||
closed.add(keyOf(surface));
|
||||
} catch (e) {
|
||||
failures.push(t("workbench.could_not_close_name_why", { name: labelOf(surface, {}), why: reason(e) }));
|
||||
|
||||
@@ -0,0 +1,113 @@
|
||||
import { useCallback, useEffect, useMemo, useRef, useState, type Dispatch, type SetStateAction } from "react";
|
||||
import { t } from "../i18n";
|
||||
import { reason } from "../i18n/kernel";
|
||||
import { host } from "../port/host";
|
||||
import type { AgentPort } from "../port/port";
|
||||
import type { ShellChoice, TerminalInfo } from "../port/terminal";
|
||||
import { openPage } from "./browser_open";
|
||||
import { lastShell, rememberShell } from "./terminal_prefs";
|
||||
import type { Surface } from "./workbench_tabs";
|
||||
|
||||
export type TerminalTab = Extract<Surface, { kind: "terminal" }>;
|
||||
|
||||
const POLL_MS = 2000;
|
||||
|
||||
interface Props {
|
||||
port: AgentPort;
|
||||
shown: boolean;
|
||||
pick: (key: string) => void;
|
||||
setDismissed: Dispatch<SetStateAction<Set<string>>>;
|
||||
onExternal: (url: string) => void;
|
||||
onError: (message: string) => void;
|
||||
// Counts the presses that ask for a terminal: the one most recently used, or a first one.
|
||||
request: number;
|
||||
}
|
||||
|
||||
/** The person's terminals as workbench tabs. The kernel holds the list, so a
|
||||
* terminal opened from another window, or before this one reloaded, is a tab
|
||||
* here too; a tab is named for the command last submitted while it is busy. */
|
||||
export function useTerminalTabs({ port, shown, pick, setDismissed, onExternal, onError, request }: Props) {
|
||||
const [list, setList] = useState<TerminalInfo[]>([]);
|
||||
const [shells, setShells] = useState<ShellChoice[]>([]);
|
||||
const [commands, setCommands] = useState<Record<string, string>>({});
|
||||
const changed = useRef(0);
|
||||
|
||||
useEffect(() => {
|
||||
if (!shown) return;
|
||||
let live = true;
|
||||
const read = () => {
|
||||
const since = changed.current;
|
||||
port.terminals().then((next) => { if (live && since === changed.current) setList((now) => (JSON.stringify(now) === JSON.stringify(next) ? now : next)); }, () => {});
|
||||
};
|
||||
read();
|
||||
const timer = window.setInterval(read, POLL_MS);
|
||||
return () => { live = false; window.clearInterval(timer); };
|
||||
}, [port, shown]);
|
||||
|
||||
const loadShells = useCallback(() => { port.terminalShells().then(setShells, () => {}); }, [port]);
|
||||
useEffect(() => { if (shown) loadShells(); }, [shown, loadShells]);
|
||||
|
||||
const add = useCallback((info: TerminalInfo) => {
|
||||
changed.current += 1;
|
||||
setList((v) => [...v.filter((x) => x.id !== info.id), info]);
|
||||
setDismissed((v) => { const n = new Set(v); n.delete(`terminal:${info.id}`); return n; });
|
||||
pick(`terminal:${info.id}`);
|
||||
}, [pick, setDismissed]);
|
||||
|
||||
const open = useCallback(async (kind = lastShell()) => {
|
||||
try {
|
||||
const info = await port.openTerminal(kind, 0, 0);
|
||||
add(info);
|
||||
const used = kind || shells.find((s) => s.path === info.shell)?.kind;
|
||||
if (used) rememberShell(used);
|
||||
} catch (e) {
|
||||
onError(t("terminal.could_not_open", { why: reason(e) }));
|
||||
}
|
||||
}, [port, add, shells, onError]);
|
||||
|
||||
const handled = useRef(request);
|
||||
useEffect(() => {
|
||||
if (request === handled.current) return;
|
||||
handled.current = request;
|
||||
const latest = list.at(-1);
|
||||
if (latest) pick(`terminal:${latest.id}`);
|
||||
else void open();
|
||||
}, [request, list, pick, open]);
|
||||
|
||||
const restart = useCallback(async (id: string) => {
|
||||
const info = await port.restartTerminal(id);
|
||||
changed.current += 1;
|
||||
setList((v) => v.map((x) => (x.id === id ? info : x)));
|
||||
}, [port]);
|
||||
|
||||
const openUrl = useCallback(async (url: string) => {
|
||||
if (!host().drawsBrowserViews()) return onExternal(url);
|
||||
try {
|
||||
pick(`browser:${(await openPage(port, url)).target}`);
|
||||
} catch (e) {
|
||||
onError(reason(e));
|
||||
}
|
||||
}, [port, pick, onExternal, onError]);
|
||||
|
||||
const [asking, setAsking] = useState<{ anchor: HTMLElement; run: () => void } | null>(null);
|
||||
const cancelAsk = useCallback(() => setAsking(null), []);
|
||||
const confirmAsk = useCallback(() => { asking?.run(); setAsking(null); }, [asking]);
|
||||
// Closing a terminal with a command still running asks first; whichever way
|
||||
// the close was asked for, it goes through here.
|
||||
const guard = useCallback((targets: Surface[], run: (targets: Surface[]) => Promise<void>) => {
|
||||
const busy = targets.find((s) => s.kind === "terminal" && s.busy);
|
||||
if (!busy) return run(targets);
|
||||
const anchor = document.querySelector<HTMLElement>(`[data-key="terminal:${busy.kind === "terminal" ? busy.id : ""}"]`) ?? document.body;
|
||||
setAsking({ anchor, run: () => void run(targets) });
|
||||
return Promise.resolve();
|
||||
}, []);
|
||||
|
||||
const submitted = useCallback((id: string, line: string) => setCommands((v) => ({ ...v, [id]: line })), []);
|
||||
|
||||
const tabs = useMemo<TerminalTab[]>(() => list.map((info, i) => ({
|
||||
kind: "terminal", id: info.id, busy: info.busy,
|
||||
name: info.busy && commands[info.id] ? commands[info.id] : list.length === 1 ? t("terminal.name") : t("terminal.n", { n: i + 1 }),
|
||||
})), [list, commands]);
|
||||
|
||||
return { terminals: list, tabs, shells, loadShells, open, restart, openUrl, submitted, asking, cancelAsk, confirmAsk, guard };
|
||||
}
|
||||
@@ -0,0 +1,108 @@
|
||||
// @vitest-environment jsdom
|
||||
import { afterEach, beforeEach, expect, it, vi } from "vitest";
|
||||
import { act, cleanup, fireEvent, render, screen, waitFor } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import "./testkit";
|
||||
|
||||
vi.mock("./TerminalLayer", () => ({
|
||||
default: ({ terminals, active, shown }: { terminals: { id: string }[]; active: string; shown: boolean }) => (
|
||||
<>{terminals.map((t) => <div key={t.id} data-testid="screen" data-shown={shown && t.id === active ? "yes" : "no"} />)}</>
|
||||
),
|
||||
}));
|
||||
|
||||
import { WorkbenchPanel } from "./WorkbenchPanel";
|
||||
import { MockPort } from "../port/mock";
|
||||
|
||||
const props = { scheme: "light" as const, shown: true, changes: [], onSurfaces: vi.fn(), onCloseManual: vi.fn(), onExternal: vi.fn() };
|
||||
const tabNames = () => [...document.querySelectorAll(".workbench-tab-pick")].map((t) => t.textContent);
|
||||
|
||||
beforeEach(() => localStorage.clear());
|
||||
afterEach(() => { cleanup(); vi.restoreAllMocks(); });
|
||||
|
||||
async function openTerminalFromLauncher(port: MockPort) {
|
||||
const view = render(<WorkbenchPanel {...props} port={port} tabs={[]} manual />);
|
||||
fireEvent.click(document.querySelector('[data-action="workbench.new"]')!);
|
||||
fireEvent.click(document.querySelector('[data-action="workbench.launch-terminal"]')!);
|
||||
await waitFor(() => expect(tabNames()).toContain("终端"));
|
||||
return view;
|
||||
}
|
||||
|
||||
it("offers a terminal in a new tab and opens it as a tab in front", async () => {
|
||||
const port = new MockPort();
|
||||
const open = vi.spyOn(port, "openTerminal");
|
||||
await openTerminalFromLauncher(port);
|
||||
expect(open).toHaveBeenCalledOnce();
|
||||
expect(document.querySelector('[role="tab"][aria-selected="true"]')?.textContent).toBe("终端");
|
||||
expect((await screen.findByTestId("screen")).getAttribute("data-shown")).toBe("yes");
|
||||
});
|
||||
|
||||
it("keeps a terminal's screen mounted but not in front when another tab is chosen", async () => {
|
||||
const port = new MockPort();
|
||||
await openTerminalFromLauncher(port);
|
||||
fireEvent.click(document.querySelector('[data-action="workbench.new"]')!);
|
||||
fireEvent.click(document.querySelector('[data-action="workbench.launch-find"]')!);
|
||||
expect((await screen.findByTestId("screen")).getAttribute("data-shown")).toBe("no");
|
||||
});
|
||||
|
||||
it("numbers terminals once there are two, and names a busy one for the command it runs with a dot", async () => {
|
||||
const port = new MockPort();
|
||||
await openTerminalFromLauncher(port);
|
||||
await port.openTerminal("bash", 80, 24);
|
||||
await waitFor(() => expect(tabNames()).toEqual(["终端 1", "终端 2"]));
|
||||
expect(document.querySelector(".workbench-tab-busy")).toBeNull();
|
||||
});
|
||||
|
||||
it("brings back the terminals the kernel already holds, as tabs, without opening another", async () => {
|
||||
const port = new MockPort();
|
||||
await port.openTerminal("bash", 80, 24);
|
||||
const open = vi.spyOn(port, "openTerminal");
|
||||
render(<WorkbenchPanel {...props} port={port} tabs={[]} manual />);
|
||||
await waitFor(() => expect(tabNames()).toEqual(["终端"]));
|
||||
expect(open).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("ends the shell when its tab is closed", async () => {
|
||||
const port = new MockPort();
|
||||
const close = vi.spyOn(port, "closeTerminal");
|
||||
await openTerminalFromLauncher(port);
|
||||
await userEvent.click(document.querySelector<HTMLElement>('[data-action="workbench.close"]')!);
|
||||
await waitFor(() => expect(close).toHaveBeenCalledOnce());
|
||||
await waitFor(() => expect(tabNames()).not.toContain("终端"));
|
||||
});
|
||||
|
||||
it("asks before closing a terminal with a command running, and ends it only when told to", async () => {
|
||||
const port = new MockPort();
|
||||
const close = vi.spyOn(port, "closeTerminal");
|
||||
await openTerminalFromLauncher(port);
|
||||
const [info] = await port.terminals();
|
||||
vi.spyOn(port, "terminals").mockResolvedValue([{ ...info, busy: true }]);
|
||||
await waitFor(() => expect(document.querySelector(".workbench-tab-busy")).not.toBeNull(), { timeout: 5000 });
|
||||
await userEvent.click(document.querySelector<HTMLElement>('[data-action="workbench.close"]')!);
|
||||
expect(await screen.findByRole("alertdialog", { name: /命令在运行/ })).toBeTruthy();
|
||||
expect(close).not.toHaveBeenCalled();
|
||||
await userEvent.click(screen.getByRole("button", { name: "取消" }));
|
||||
expect(close).not.toHaveBeenCalled();
|
||||
await userEvent.click(document.querySelector<HTMLElement>('[data-action="workbench.close"]')!);
|
||||
await userEvent.click(await screen.findByRole("button", { name: "结束并关闭" }));
|
||||
await waitFor(() => expect(close).toHaveBeenCalledOnce());
|
||||
});
|
||||
|
||||
it("opens the latest terminal on the shortcut's request, or a first one when there is none", async () => {
|
||||
const port = new MockPort();
|
||||
const open = vi.spyOn(port, "openTerminal");
|
||||
const view = render(<WorkbenchPanel {...props} port={port} tabs={[]} manual terminalRequest={0} />);
|
||||
view.rerender(<WorkbenchPanel {...props} port={port} tabs={[]} manual terminalRequest={1} />);
|
||||
await waitFor(() => expect(open).toHaveBeenCalledOnce());
|
||||
await waitFor(() => expect(tabNames()).toEqual(["终端"]));
|
||||
view.rerender(<WorkbenchPanel {...props} port={port} tabs={[]} manual terminalRequest={2} />);
|
||||
await act(async () => {});
|
||||
expect(open).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it("remembers the shell last chosen for the next terminal", async () => {
|
||||
const port = new MockPort();
|
||||
const open = vi.spyOn(port, "openTerminal");
|
||||
localStorage.setItem("rx-terminal-shell", "pwsh");
|
||||
await openTerminalFromLauncher(port);
|
||||
expect(open).toHaveBeenCalledWith("pwsh", 0, 0);
|
||||
});
|
||||
@@ -7,10 +7,11 @@ export type Surface =
|
||||
| { kind: "file"; path: string }
|
||||
| { kind: "agent"; ref: string; name: string }
|
||||
| { kind: "job"; id: string; name: string }
|
||||
| { kind: "terminal"; id: string; name: string; busy: boolean }
|
||||
| { kind: "changes" };
|
||||
export function keyOf(s: Surface) {
|
||||
if (s.kind === "changes") return "changes:";
|
||||
return `${s.kind}:${s.kind === "manual" || s.kind === "job" ? s.id : s.kind === "browser" ? s.tab.target : s.kind === "agent" ? s.ref : s.path}`;
|
||||
return `${s.kind}:${s.kind === "manual" || s.kind === "job" || s.kind === "terminal" ? s.id : s.kind === "browser" ? s.tab.target : s.kind === "agent" ? s.ref : s.path}`;
|
||||
}
|
||||
export function labelOf(s: Surface, hosts: Record<string, string>) {
|
||||
if (s.kind === "changes") return t("common.changes");
|
||||
@@ -18,7 +19,7 @@ export function labelOf(s: Surface, hosts: Record<string, string>) {
|
||||
? hosts[s.id] || t("workbench.browser")
|
||||
: s.kind === "browser"
|
||||
? s.tab.title || s.tab.url || t("common.blank_page")
|
||||
: s.kind === "agent" || s.kind === "job"
|
||||
: s.kind === "agent" || s.kind === "job" || s.kind === "terminal"
|
||||
? s.name
|
||||
: s.path.split("/").at(-1) || s.path;
|
||||
}
|
||||
@@ -52,12 +52,14 @@ require (
|
||||
)
|
||||
|
||||
require (
|
||||
github.com/UserExistsError/conpty v0.1.4 // indirect
|
||||
github.com/charmbracelet/ultraviolet v0.0.0-20261008173134-6b8d4baf91b4 // indirect
|
||||
github.com/charmbracelet/x/term v0.2.2 // indirect
|
||||
github.com/charmbracelet/x/termios v0.1.1 // indirect
|
||||
github.com/charmbracelet/x/windows v0.2.2 // indirect
|
||||
github.com/clipperhouse/displaywidth v0.11.0 // indirect
|
||||
github.com/clipperhouse/uax29/v2 v2.7.0 // indirect
|
||||
github.com/creack/pty v1.1.24 // indirect
|
||||
github.com/danieljoos/wincred v1.2.3 // indirect
|
||||
github.com/dlclark/regexp2/v2 v2.2.1 // indirect
|
||||
github.com/dustin/go-humanize v1.1.0 // indirect
|
||||
|
||||
@@ -12,6 +12,8 @@ github.com/BurntSushi/toml v1.6.0 h1:dRaEfpa2VI55EwlIW72hMRHdWouJeRF7TPYhI+AUQjk
|
||||
github.com/BurntSushi/toml v1.6.0/go.mod h1:ukJfTF/6rtPPRCnwkur4qwRxa8vTRFBF0uk2lLoLwho=
|
||||
github.com/MakeNowJust/heredoc v1.0.0 h1:cXCdzVdstXyiTqTvfqk9SDHpKNjxuom+DOlyEeQ4pzQ=
|
||||
github.com/MakeNowJust/heredoc v1.0.0/go.mod h1:mG5amYoWBHf8vpLOuehzbGGw0EHxpZZ6lCpQ4fNJ8LE=
|
||||
github.com/UserExistsError/conpty v0.1.4 h1:+3FhJhiqhyEJa+K5qaK3/w6w+sN3Nh9O9VbJyBS02to=
|
||||
github.com/UserExistsError/conpty v0.1.4/go.mod h1:PDglKIkX3O/2xVk0MV9a6bCWxRmPVfxqZoTG/5sSd9I=
|
||||
github.com/alecthomas/assert/v2 v2.11.0 h1:2Q9r3ki8+JYXvGsDyBXwH3LcJ+WK5D0gc5E8vS6K3D0=
|
||||
github.com/alecthomas/assert/v2 v2.11.0/go.mod h1:Bze95FyfUr7x34QZrjL+XP+0qgp/zg8yS+TtBj1WA3k=
|
||||
github.com/alecthomas/chroma/v2 v2.27.0 h1:FodwmyOBgJULFYmDqibcp9pvfDLWdtPRh9v/r5BXYZs=
|
||||
@@ -42,6 +44,8 @@ github.com/clipperhouse/displaywidth v0.11.0 h1:lBc6kY44VFw+TDx4I8opi/EtL9m20WSE
|
||||
github.com/clipperhouse/displaywidth v0.11.0/go.mod h1:bkrFNkf81G8HyVqmKGxsPufD3JhNl3dSqnGhOoSD/o0=
|
||||
github.com/clipperhouse/uax29/v2 v2.7.0 h1:+gs4oBZ2gPfVrKPthwbMzWZDaAFPGYK72F0NJv2v7Vk=
|
||||
github.com/clipperhouse/uax29/v2 v2.7.0/go.mod h1:EFJ2TJMRUaplDxHKj1qAEhCtQPW2tJSwu5BF98AuoVM=
|
||||
github.com/creack/pty v1.1.24 h1:bJrF4RRfyJnbTJqzRLHzcGaZK1NeM5kTC9jGgovnR1s=
|
||||
github.com/creack/pty v1.1.24/go.mod h1:08sCNb52WyoAwi2QDyzUCTgcvVFhUzewun7wtTfvcwE=
|
||||
github.com/danieljoos/wincred v1.2.3 h1:v7dZC2x32Ut3nEfRH+vhoZGvN72+dQ/snVXo/vMFLdQ=
|
||||
github.com/danieljoos/wincred v1.2.3/go.mod h1:6qqX0WNrS4RzPZ1tnroDzq9kY3fu1KwE7MRLQK4X0bs=
|
||||
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
@@ -172,6 +176,7 @@ golang.org/x/net v0.62.0/go.mod h1:WgDCOTH8iwtB66T4/GfI94AHo/hhdb7Hah9NDseQfiI=
|
||||
golang.org/x/sync v0.24.0 h1:iqiDrGoDgyuvAKrhLQthUEzU/R378N78gXbBjxed7oA=
|
||||
golang.org/x/sync v0.24.0/go.mod h1:sUUOizhqBxiL6pEWpqNLUiaJn1ShEbZ6BBqskPbjZm0=
|
||||
golang.org/x/sys v0.1.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.49.0 h1:XbzkgYJHdqh/8m2Uu0W/dQv8nktxx4BFHp1M0gROTXA=
|
||||
golang.org/x/sys v0.49.0/go.mod h1:hNLxWAXmnKAxqDtdwIYC4bM9oQPEecfsnNMuSxOs3og=
|
||||
golang.org/x/term v0.47.0 h1:cpyOwQkr2Nzxe2p6KnrTG7Ei2wtzjsuSjhpXXepZ/BM=
|
||||
|
||||
@@ -717,3 +717,19 @@ func OpenBindRefusal(cfg config.ServeConfig, addr string, allow bool) error {
|
||||
}
|
||||
return fmt.Errorf("auth none on %s would let anyone on the network read sessions and history; use --auth token or password, bind to 127.0.0.1, or pass --allow-open-network", addr)
|
||||
}
|
||||
|
||||
// AuthToken returns the pre-shared token when in token mode, or "" otherwise.
|
||||
func (s *Server) AuthToken() string {
|
||||
if s.auth == nil {
|
||||
return ""
|
||||
}
|
||||
return s.auth.Token()
|
||||
}
|
||||
|
||||
// AuthMode returns the authentication mode: "none", "token", or "password".
|
||||
func (s *Server) AuthMode() string {
|
||||
if s.auth == nil {
|
||||
return "none"
|
||||
}
|
||||
return s.auth.Mode()
|
||||
}
|
||||
@@ -33,6 +33,14 @@ var hostOnlyRoutes = []hostRoute{
|
||||
{http.MethodPost, "/market/install"},
|
||||
{http.MethodPost, "/market/mine/install"},
|
||||
{http.MethodPost, "/workspace-trust"},
|
||||
{http.MethodGet, "/terminals/shells"},
|
||||
{http.MethodGet, "/terminals"},
|
||||
{http.MethodPost, "/terminals"},
|
||||
{http.MethodDelete, "/terminals/{id}"},
|
||||
{http.MethodPost, "/terminals/{id}/restart"},
|
||||
{http.MethodPost, "/terminals/{id}/resize"},
|
||||
{http.MethodPost, "/terminals/{id}/input"},
|
||||
{http.MethodGet, "/terminals/{id}/stream"},
|
||||
}
|
||||
|
||||
// GET /history/tool/{id} is not listed on purpose: it is a window onto what GET /history
|
||||
|
||||
@@ -26,6 +26,7 @@ func (h *Hub) Close(id string) error {
|
||||
|
||||
if rt.Local() {
|
||||
rt.Server.preparations.close()
|
||||
rt.Server.terminals.CloseAll()
|
||||
rt.Server.bindMu.Lock()
|
||||
defer rt.Server.bindMu.Unlock()
|
||||
}
|
||||
|
||||
@@ -23,7 +23,7 @@ func (s *Server) routes() http.Handler {
|
||||
s.registerHistoryRoutes(mux)
|
||||
mux.HandleFunc("GET /context", s.context)
|
||||
mux.HandleFunc("GET /adjudications", s.adjudications)
|
||||
s.registerBrowserRoutes(mux)
|
||||
s.registerWorkbenchRoutes(mux)
|
||||
s.registerExecutionGraphRoutes(mux)
|
||||
mux.HandleFunc("POST /context/window", s.setContextWindow)
|
||||
mux.HandleFunc("POST /submit", s.submit)
|
||||
|
||||
@@ -24,6 +24,7 @@ import (
|
||||
"reasonix/internal/contract/event"
|
||||
"reasonix/internal/contract/provider"
|
||||
"reasonix/internal/contract/surface"
|
||||
"reasonix/internal/platform/terminal"
|
||||
"reasonix/internal/runtime/delegation"
|
||||
"reasonix/internal/session/control"
|
||||
"reasonix/internal/state/sessionstore"
|
||||
@@ -37,6 +38,7 @@ var logoWordmarkSVG []byte
|
||||
// Server wires a controller to its HTTP surface. The controller's events must
|
||||
// reach the Broadcaster — directly, or through the host's SetPaneSink wrapper.
|
||||
type Server struct {
|
||||
terminals *terminal.Manager // the shells this pane opened for the person
|
||||
mu sync.RWMutex // guards ctrl and paneSink, which rebuild paths swap and read
|
||||
// bindMu serializes every entry point that changes the active session
|
||||
// path or controller generation — /resume, /new, /fork, switchModel, and
|
||||
@@ -107,6 +109,7 @@ func New(ctrl control.SessionAPI, bc *Broadcaster, serveCfg config.ServeConfig)
|
||||
bc: bc,
|
||||
titles: newTitleCache(ctrl.SessionDir()),
|
||||
wire: &wireLog{},
|
||||
terminals: terminal.NewManager(),
|
||||
fill: newTitleFiller(),
|
||||
auth: newAuthGate(serveCfg),
|
||||
}
|
||||
@@ -142,22 +145,6 @@ func (s *Server) ctl() control.SessionAPI {
|
||||
// the interleaving bindMu exists to prevent; production never sets it.
|
||||
var resumeBindHookForTest func()
|
||||
|
||||
// AuthToken returns the pre-shared token when in token mode, or "" otherwise.
|
||||
func (s *Server) AuthToken() string {
|
||||
if s.auth == nil {
|
||||
return ""
|
||||
}
|
||||
return s.auth.Token()
|
||||
}
|
||||
|
||||
// AuthMode returns the authentication mode: "none", "token", or "password".
|
||||
func (s *Server) AuthMode() string {
|
||||
if s.auth == nil {
|
||||
return "none"
|
||||
}
|
||||
return s.auth.Mode()
|
||||
}
|
||||
|
||||
// switchModel rebuilds the controller with a new model, carrying over the
|
||||
// conversation history. This replicates the TUI/desktop model-switch path.
|
||||
//
|
||||
|
||||
@@ -0,0 +1,226 @@
|
||||
package serve
|
||||
|
||||
import (
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"os"
|
||||
|
||||
"reasonix/internal/platform/terminal"
|
||||
)
|
||||
|
||||
const (
|
||||
codeTerminalNotFound = "terminal.not_found"
|
||||
codeTerminalShell = "terminal.shell_missing"
|
||||
codeTerminalStart = "terminal.start_failed"
|
||||
codeTerminalExited = "terminal.exited"
|
||||
codeTerminalLimit = "terminal.limit"
|
||||
terminalInputLimit = 1 << 16
|
||||
)
|
||||
|
||||
// registerWorkbenchRoutes is what the panes beside the conversation call on.
|
||||
func (s *Server) registerWorkbenchRoutes(mux *http.ServeMux) {
|
||||
s.registerBrowserRoutes(mux)
|
||||
s.registerTerminalRoutes(mux)
|
||||
}
|
||||
|
||||
func (s *Server) registerTerminalRoutes(mux *http.ServeMux) {
|
||||
mux.HandleFunc("GET /terminals/shells", s.terminalShells)
|
||||
mux.HandleFunc("GET /terminals", s.terminalList)
|
||||
mux.HandleFunc("POST /terminals", s.terminalOpen)
|
||||
mux.HandleFunc("DELETE /terminals/{id}", s.terminalClose)
|
||||
mux.HandleFunc("POST /terminals/{id}/restart", s.terminalRestart)
|
||||
mux.HandleFunc("POST /terminals/{id}/resize", s.terminalResize)
|
||||
mux.HandleFunc("POST /terminals/{id}/input", s.terminalInput)
|
||||
mux.HandleFunc("GET /terminals/{id}/stream", s.terminalStream)
|
||||
}
|
||||
|
||||
// refuseTerminal names a terminal failure by the code its producer gave it.
|
||||
func refuseTerminal(w http.ResponseWriter, err error) {
|
||||
switch terminal.CodeOf(err) {
|
||||
case terminal.CodeNotFound:
|
||||
refuse(w, http.StatusNotFound, codeTerminalNotFound, err.Error(), nil)
|
||||
case terminal.CodeShellMissing:
|
||||
refuse(w, http.StatusBadRequest, codeTerminalShell, err.Error(), nil)
|
||||
case terminal.CodeExited:
|
||||
refuse(w, http.StatusConflict, codeTerminalExited, err.Error(), nil)
|
||||
case terminal.CodeLimit:
|
||||
refuse(w, http.StatusConflict, codeTerminalLimit, err.Error(), nil)
|
||||
default:
|
||||
refuse(w, http.StatusInternalServerError, codeTerminalStart, err.Error(), nil)
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Server) terminalShells(w http.ResponseWriter, r *http.Request) {
|
||||
if refuseDevice(w, r) {
|
||||
return
|
||||
}
|
||||
writeJSON(w, terminal.Shells(os.Getenv))
|
||||
}
|
||||
|
||||
func (s *Server) terminalList(w http.ResponseWriter, r *http.Request) {
|
||||
if refuseDevice(w, r) {
|
||||
return
|
||||
}
|
||||
writeJSON(w, s.terminals.List())
|
||||
}
|
||||
|
||||
// terminalOpen starts the shell the person picked, or their best one, in the
|
||||
// workspace folder.
|
||||
func (s *Server) terminalOpen(w http.ResponseWriter, r *http.Request) {
|
||||
if refuseDevice(w, r) {
|
||||
return
|
||||
}
|
||||
var req struct {
|
||||
Shell string `json:"shell"`
|
||||
Cols uint16 `json:"cols"`
|
||||
Rows uint16 `json:"rows"`
|
||||
}
|
||||
if err := json.NewDecoder(http.MaxBytesReader(w, r.Body, terminalInputLimit)).Decode(&req); err != nil && !errors.Is(err, io.EOF) {
|
||||
badBody(w)
|
||||
return
|
||||
}
|
||||
var sh *terminal.Shell
|
||||
for _, c := range terminal.Shells(os.Getenv) {
|
||||
if req.Shell == "" || c.Kind == req.Shell {
|
||||
sh = &c
|
||||
break
|
||||
}
|
||||
}
|
||||
if sh == nil {
|
||||
refuse(w, http.StatusBadRequest, codeTerminalShell, "that shell is not on this machine", nil)
|
||||
return
|
||||
}
|
||||
sess, err := s.terminals.Open(terminal.Options{
|
||||
Shell: sh.Path, Args: sh.Args, Name: sh.Name,
|
||||
Dir: s.ctl().WorkspaceRoot(), Env: terminal.PersonEnv(os.Environ()),
|
||||
Cols: req.Cols, Rows: req.Rows,
|
||||
})
|
||||
if err != nil {
|
||||
refuseTerminal(w, err)
|
||||
return
|
||||
}
|
||||
writeJSON(w, sess.Info())
|
||||
}
|
||||
|
||||
func (s *Server) terminalClose(w http.ResponseWriter, r *http.Request) {
|
||||
if refuseDevice(w, r) {
|
||||
return
|
||||
}
|
||||
if err := s.terminals.Close(r.PathValue("id")); err != nil {
|
||||
refuseTerminal(w, err)
|
||||
return
|
||||
}
|
||||
writeJSON(w, map[string]bool{"ok": true})
|
||||
}
|
||||
|
||||
func (s *Server) terminalRestart(w http.ResponseWriter, r *http.Request) {
|
||||
if refuseDevice(w, r) {
|
||||
return
|
||||
}
|
||||
sess, err := s.terminals.Get(r.PathValue("id"))
|
||||
if err == nil {
|
||||
err = sess.Restart()
|
||||
}
|
||||
if err != nil {
|
||||
refuseTerminal(w, err)
|
||||
return
|
||||
}
|
||||
writeJSON(w, sess.Info())
|
||||
}
|
||||
|
||||
func (s *Server) terminalResize(w http.ResponseWriter, r *http.Request) {
|
||||
if refuseDevice(w, r) {
|
||||
return
|
||||
}
|
||||
var req struct {
|
||||
Cols uint16 `json:"cols"`
|
||||
Rows uint16 `json:"rows"`
|
||||
}
|
||||
if err := json.NewDecoder(http.MaxBytesReader(w, r.Body, 1<<10)).Decode(&req); err != nil {
|
||||
badBody(w)
|
||||
return
|
||||
}
|
||||
sess, err := s.terminals.Get(r.PathValue("id"))
|
||||
if err == nil {
|
||||
err = sess.Resize(req.Cols, req.Rows)
|
||||
}
|
||||
if err != nil {
|
||||
refuseTerminal(w, err)
|
||||
return
|
||||
}
|
||||
writeJSON(w, map[string]bool{"ok": true})
|
||||
}
|
||||
|
||||
// terminalInput carries the person's keystrokes as they typed them.
|
||||
func (s *Server) terminalInput(w http.ResponseWriter, r *http.Request) {
|
||||
if refuseDevice(w, r) {
|
||||
return
|
||||
}
|
||||
var req struct {
|
||||
Data string `json:"data"`
|
||||
}
|
||||
if err := json.NewDecoder(http.MaxBytesReader(w, r.Body, terminalInputLimit)).Decode(&req); err != nil {
|
||||
badBody(w)
|
||||
return
|
||||
}
|
||||
sess, err := s.terminals.Get(r.PathValue("id"))
|
||||
if err == nil {
|
||||
err = sess.Write([]byte(req.Data))
|
||||
}
|
||||
if err != nil {
|
||||
refuseTerminal(w, err)
|
||||
return
|
||||
}
|
||||
writeJSON(w, map[string]bool{"ok": true})
|
||||
}
|
||||
|
||||
type terminalFrame struct {
|
||||
Kind string `json:"kind"` // replay | out | exit
|
||||
Data string `json:"data,omitempty"`
|
||||
Code *int `json:"code,omitempty"`
|
||||
}
|
||||
|
||||
// terminalStream sends what the shell held, then what it writes, as base64 so
|
||||
// bytes that are not text cross intact, and ends with the exit code.
|
||||
func (s *Server) terminalStream(w http.ResponseWriter, r *http.Request) {
|
||||
if refuseDevice(w, r) {
|
||||
return
|
||||
}
|
||||
flusher, ok := w.(http.Flusher)
|
||||
if !ok {
|
||||
refuse(w, http.StatusInternalServerError, "stream.unsupported", "this transport cannot stream", nil)
|
||||
return
|
||||
}
|
||||
sess, err := s.terminals.Get(r.PathValue("id"))
|
||||
if err != nil {
|
||||
refuseTerminal(w, err)
|
||||
return
|
||||
}
|
||||
w.Header().Set("Content-Type", "text/event-stream")
|
||||
w.Header().Set("Cache-Control", "no-cache")
|
||||
send := func(f terminalFrame) {
|
||||
b, _ := json.Marshal(f)
|
||||
fmt.Fprintf(w, "data: %s\n\n", b)
|
||||
flusher.Flush()
|
||||
}
|
||||
sub := sess.Subscribe()
|
||||
defer sub.Cancel()
|
||||
send(terminalFrame{Kind: "replay", Data: base64.StdEncoding.EncodeToString(sub.Replay)})
|
||||
for {
|
||||
select {
|
||||
case c, open := <-sub.Output:
|
||||
if !open {
|
||||
<-sess.Done()
|
||||
send(terminalFrame{Kind: "exit", Code: sess.Info().ExitCode})
|
||||
return
|
||||
}
|
||||
send(terminalFrame{Kind: "out", Data: base64.StdEncoding.EncodeToString(c)})
|
||||
case <-r.Context().Done():
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,155 @@
|
||||
package serve
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"runtime"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"reasonix/internal/platform/terminal"
|
||||
"reasonix/internal/session/control"
|
||||
)
|
||||
|
||||
type rootCtl struct {
|
||||
control.SessionAPI
|
||||
root string
|
||||
}
|
||||
|
||||
func (r rootCtl) WorkspaceRoot() string { return r.root }
|
||||
|
||||
func terminalServer(t *testing.T) (*Server, *http.ServeMux) {
|
||||
t.Helper()
|
||||
s := &Server{ctrl: rootCtl{root: t.TempDir()}, terminals: terminal.NewManager()}
|
||||
t.Cleanup(s.terminals.CloseAll)
|
||||
mux := http.NewServeMux()
|
||||
s.registerTerminalRoutes(mux)
|
||||
return s, mux
|
||||
}
|
||||
|
||||
func terminalCall(mux *http.ServeMux, method, path, body string, device bool) *httptest.ResponseRecorder {
|
||||
req := httptest.NewRequest(method, path, strings.NewReader(body))
|
||||
if device {
|
||||
req = req.WithContext(withDeviceReach(req.Context(), "phone-1", 1))
|
||||
}
|
||||
rec := httptest.NewRecorder()
|
||||
mux.ServeHTTP(rec, req)
|
||||
return rec
|
||||
}
|
||||
|
||||
func TestAPairedDeviceCannotUseTheHostsTerminals(t *testing.T) {
|
||||
s, mux := terminalServer(t)
|
||||
for _, rt := range []struct{ method, path string }{
|
||||
{http.MethodGet, "/terminals/shells"},
|
||||
{http.MethodGet, "/terminals"},
|
||||
{http.MethodPost, "/terminals"},
|
||||
{http.MethodDelete, "/terminals/x"},
|
||||
{http.MethodPost, "/terminals/x/restart"},
|
||||
{http.MethodPost, "/terminals/x/resize"},
|
||||
{http.MethodPost, "/terminals/x/input"},
|
||||
{http.MethodGet, "/terminals/x/stream"},
|
||||
} {
|
||||
rec := terminalCall(mux, rt.method, rt.path, `{}`, true)
|
||||
var reason Reason
|
||||
_ = json.Unmarshal(rec.Body.Bytes(), &reason)
|
||||
if rec.Code != http.StatusNotFound || reason.Code != codeDeviceHostOnly {
|
||||
t.Errorf("%s %s from a device: status %d code %q, want 404 %s", rt.method, rt.path, rec.Code, reason.Code, codeDeviceHostOnly)
|
||||
}
|
||||
}
|
||||
if n := len(s.terminals.List()); n != 0 {
|
||||
t.Fatalf("a refused device opened %d terminals", n)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAnUnknownTerminalIsRefusedByCode(t *testing.T) {
|
||||
_, mux := terminalServer(t)
|
||||
for _, rt := range []struct{ method, path string }{
|
||||
{http.MethodDelete, "/terminals/nope"},
|
||||
{http.MethodPost, "/terminals/nope/input"},
|
||||
{http.MethodPost, "/terminals/nope/resize"},
|
||||
{http.MethodPost, "/terminals/nope/restart"},
|
||||
} {
|
||||
rec := terminalCall(mux, rt.method, rt.path, `{"data":"x"}`, false)
|
||||
var reason Reason
|
||||
_ = json.Unmarshal(rec.Body.Bytes(), &reason)
|
||||
if rec.Code != http.StatusNotFound || reason.Code != codeTerminalNotFound {
|
||||
t.Errorf("%s %s: status %d code %q, want 404 %s", rt.method, rt.path, rec.Code, reason.Code, codeTerminalNotFound)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestATerminalOpensTakesInputAndStreamsItsOutputToTheEnd(t *testing.T) {
|
||||
shell := "cmd"
|
||||
exit := "exit\r\n"
|
||||
echo := "echo served-probe\r\n"
|
||||
if runtime.GOOS != "windows" {
|
||||
shell, exit, echo = "sh", "exit\n", "echo served-probe\n"
|
||||
}
|
||||
_, mux := terminalServer(t)
|
||||
srv := httptest.NewServer(mux)
|
||||
defer srv.Close()
|
||||
|
||||
res, err := http.Post(srv.URL+"/terminals", "application/json", strings.NewReader(`{"shell":"`+shell+`"}`))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var info terminal.Info
|
||||
_ = json.NewDecoder(res.Body).Decode(&info)
|
||||
res.Body.Close()
|
||||
if res.StatusCode != http.StatusOK || info.ID == "" {
|
||||
t.Fatalf("open: status %d info %+v", res.StatusCode, info)
|
||||
}
|
||||
|
||||
stream, err := http.Get(srv.URL + "/terminals/" + info.ID + "/stream")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer stream.Body.Close()
|
||||
|
||||
for _, keys := range []string{echo, exit} {
|
||||
body, _ := json.Marshal(map[string]string{"data": keys})
|
||||
r, err := http.Post(srv.URL+"/terminals/"+info.ID+"/input", "application/json", strings.NewReader(string(body)))
|
||||
if err != nil || r.StatusCode != http.StatusOK {
|
||||
t.Fatalf("input: %v status %v", err, r)
|
||||
}
|
||||
r.Body.Close()
|
||||
}
|
||||
|
||||
var out strings.Builder
|
||||
var last terminalFrame
|
||||
done := make(chan struct{})
|
||||
go func() {
|
||||
defer close(done)
|
||||
sc := bufio.NewScanner(stream.Body)
|
||||
sc.Buffer(make([]byte, 1<<20), 1<<20)
|
||||
for sc.Scan() {
|
||||
line, ok := strings.CutPrefix(sc.Text(), "data: ")
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
var f terminalFrame
|
||||
if json.Unmarshal([]byte(line), &f) != nil {
|
||||
continue
|
||||
}
|
||||
if b, err := base64.StdEncoding.DecodeString(f.Data); err == nil {
|
||||
out.Write(b)
|
||||
}
|
||||
last = f
|
||||
}
|
||||
}()
|
||||
select {
|
||||
case <-done:
|
||||
case <-time.After(20 * time.Second):
|
||||
t.Fatalf("the stream did not end; output so far %q", out.String())
|
||||
}
|
||||
if !strings.Contains(out.String(), "served-probe") {
|
||||
t.Fatalf("stream %q lacks the echoed text", out.String())
|
||||
}
|
||||
if last.Kind != "exit" || last.Code == nil || *last.Code != 0 {
|
||||
t.Fatalf("the stream must end with the exit code, got %+v", last)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
//go:build windows
|
||||
|
||||
package terminal
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"unsafe"
|
||||
|
||||
"golang.org/x/sys/windows"
|
||||
)
|
||||
|
||||
type proc struct {
|
||||
pid uint32
|
||||
name string
|
||||
console uint32
|
||||
}
|
||||
|
||||
// helpers are console and subsystem processes that live beside a shell without
|
||||
// being a command it waits on.
|
||||
var helpers = map[string]bool{"conhost.exe": true, "openconsole.exe": true, "wslhost.exe": true}
|
||||
|
||||
// Busy is whether something other than the shell is attached to the shell's
|
||||
// console. The console says it, not the parent chain: an MSYS child is not
|
||||
// parented by the shell that forked it. A shell may also sit behind a launcher
|
||||
// of its own name (Git's bin\bash.exe starts the real bash), so processes with
|
||||
// the shell's image name are the shell; a subshell of the same name reads as idle.
|
||||
func (p *winPty) Busy() bool { return busyOn(uint32(p.c.Pid()), snapshot()) }
|
||||
|
||||
func snapshot() []proc {
|
||||
snap, err := windows.CreateToolhelp32Snapshot(windows.TH32CS_SNAPPROCESS, 0)
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
defer windows.CloseHandle(snap)
|
||||
var e windows.ProcessEntry32
|
||||
e.Size = uint32(unsafe.Sizeof(e))
|
||||
var all []proc
|
||||
for err = windows.Process32First(snap, &e); err == nil; err = windows.Process32Next(snap, &e) {
|
||||
all = append(all, proc{pid: e.ProcessID, name: strings.ToLower(windows.UTF16ToString(e.ExeFile[:])), console: consoleOf(e.ProcessID)})
|
||||
}
|
||||
return all
|
||||
}
|
||||
|
||||
// consoleOf is the process that hosts the console pid is attached to, or 0.
|
||||
func consoleOf(pid uint32) uint32 {
|
||||
h, err := windows.OpenProcess(windows.PROCESS_QUERY_LIMITED_INFORMATION, false, pid)
|
||||
if err != nil {
|
||||
return 0
|
||||
}
|
||||
defer windows.CloseHandle(h)
|
||||
var host uintptr
|
||||
if err := windows.NtQueryInformationProcess(h, windows.ProcessConsoleHostProcess, unsafe.Pointer(&host), uint32(unsafe.Sizeof(host)), nil); err != nil {
|
||||
return 0
|
||||
}
|
||||
return uint32(host &^ 3)
|
||||
}
|
||||
|
||||
func busyOn(root uint32, all []proc) bool {
|
||||
var shell proc
|
||||
for _, p := range all {
|
||||
if p.pid == root {
|
||||
shell = p
|
||||
}
|
||||
}
|
||||
if shell.name == "" || shell.console == 0 {
|
||||
return false
|
||||
}
|
||||
for _, p := range all {
|
||||
if p.console == shell.console && p.pid != root && p.name != shell.name && !helpers[p.name] {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
//go:build windows
|
||||
|
||||
package terminal
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestBusyOnReadsTheConsoleNotTheParentChain(t *testing.T) {
|
||||
const console = 77
|
||||
tree := []proc{
|
||||
{10, "bash.exe", console}, {11, "bash.exe", console}, {12, "conhost.exe", 0},
|
||||
{20, "sleep.exe", 99},
|
||||
}
|
||||
if busyOn(10, tree) {
|
||||
t.Fatal("a bash behind its launcher reported busy while idle, or another console's process was counted")
|
||||
}
|
||||
tree = append(tree, proc{21, "sleep.exe", console})
|
||||
if !busyOn(10, tree) {
|
||||
t.Fatal("a command attached to the shell's console did not report busy")
|
||||
}
|
||||
if busyOn(99, tree) {
|
||||
t.Fatal("an unknown root reported busy")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
// Package terminal runs the shells a person opens for themselves in the
|
||||
// workbench.
|
||||
//
|
||||
// A terminal is not the agent's: its commands are the person's own, run as them
|
||||
// with no model, permission or sandbox in between, and the model sees none of its
|
||||
// output unless the person sends a selection to the conversation. That boundary
|
||||
// is why the package knows nothing of sessions or tools.
|
||||
//
|
||||
// A Session owns one pseudo-terminal. Its output is kept in a bounded ring so a
|
||||
// window that reconnects, or opens after the shell began, replays what it
|
||||
// missed; a process that exits is kept, with its code, until the person closes
|
||||
// it. Every failure carries a Code.
|
||||
package terminal
|
||||
@@ -0,0 +1,19 @@
|
||||
package terminal
|
||||
|
||||
import "strings"
|
||||
|
||||
// PersonEnv is the environment a person's own shell is started with: the host's,
|
||||
// minus what only the host is meant to hold. The shell is not a tool the model
|
||||
// drives, so the provider keys and the host's own settings stay behind.
|
||||
func PersonEnv(env []string) []string {
|
||||
out := make([]string, 0, len(env))
|
||||
for _, kv := range env {
|
||||
key, _, _ := strings.Cut(kv, "=")
|
||||
key = strings.ToUpper(key)
|
||||
if strings.HasPrefix(key, "REASONIX_") || strings.HasSuffix(key, "_API_KEY") {
|
||||
continue
|
||||
}
|
||||
out = append(out, kv)
|
||||
}
|
||||
return out
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
package terminal
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
)
|
||||
|
||||
// Codes name why an operation failed; the detail beside one is for a reader.
|
||||
const (
|
||||
CodeNotFound = "terminal.not_found"
|
||||
CodeShellMissing = "terminal.shell_missing"
|
||||
CodeStartFailed = "terminal.start_failed"
|
||||
CodeExited = "terminal.exited"
|
||||
CodeLimit = "terminal.limit"
|
||||
)
|
||||
|
||||
// Error is a failure with an identity a caller can tell apart.
|
||||
type Error struct {
|
||||
Code string
|
||||
Detail string
|
||||
}
|
||||
|
||||
func (e *Error) Error() string { return e.Code + ": " + e.Detail }
|
||||
|
||||
func fail(code, format string, args ...any) *Error {
|
||||
return &Error{Code: code, Detail: fmt.Sprintf(format, args...)}
|
||||
}
|
||||
|
||||
// CodeOf is the Code of err when it is a terminal failure, and "" otherwise.
|
||||
func CodeOf(err error) string {
|
||||
var e *Error
|
||||
if errors.As(err, &e) {
|
||||
return e.Code
|
||||
}
|
||||
return ""
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
package terminal
|
||||
|
||||
import "io"
|
||||
|
||||
// pty is the platform's pseudo-terminal around one running shell.
|
||||
type pty interface {
|
||||
io.ReadWriteCloser
|
||||
Resize(cols, rows uint16) error
|
||||
// Wait returns the shell's exit code once it ends.
|
||||
Wait() (int, error)
|
||||
Kill() error
|
||||
// Busy is whether the shell is running something it started and waits on.
|
||||
Busy() bool
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
//go:build !windows
|
||||
|
||||
package terminal
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"os"
|
||||
"os/exec"
|
||||
"sync"
|
||||
|
||||
cpty "github.com/creack/pty"
|
||||
"golang.org/x/sys/unix"
|
||||
)
|
||||
|
||||
type unixPty struct {
|
||||
f *os.File
|
||||
cmd *exec.Cmd
|
||||
once sync.Once
|
||||
}
|
||||
|
||||
func startPty(o Options) (pty, error) {
|
||||
cmd := exec.Command(o.Shell, o.Args...)
|
||||
cmd.Dir = o.Dir
|
||||
if len(o.Env) > 0 {
|
||||
cmd.Env = o.Env
|
||||
}
|
||||
f, err := cpty.StartWithSize(cmd, &cpty.Winsize{Cols: o.Cols, Rows: o.Rows})
|
||||
if err != nil {
|
||||
return nil, fail(CodeStartFailed, "%v", err)
|
||||
}
|
||||
return &unixPty{f: f, cmd: cmd}, nil
|
||||
}
|
||||
|
||||
func (p *unixPty) Read(b []byte) (int, error) { return p.f.Read(b) }
|
||||
func (p *unixPty) Write(b []byte) (int, error) { return p.f.Write(b) }
|
||||
func (p *unixPty) Close() error {
|
||||
var err error
|
||||
p.once.Do(func() { err = p.f.Close() })
|
||||
return err
|
||||
}
|
||||
func (p *unixPty) Resize(cols, rows uint16) error {
|
||||
return cpty.Setsize(p.f, &cpty.Winsize{Cols: cols, Rows: rows})
|
||||
}
|
||||
|
||||
func (p *unixPty) Wait() (int, error) {
|
||||
err := p.cmd.Wait()
|
||||
var exit *exec.ExitError
|
||||
if errors.As(err, &exit) {
|
||||
return exit.ExitCode(), nil
|
||||
}
|
||||
if err != nil {
|
||||
return -1, err
|
||||
}
|
||||
return 0, nil
|
||||
}
|
||||
|
||||
func (p *unixPty) Kill() error {
|
||||
if p.cmd.Process == nil {
|
||||
return nil
|
||||
}
|
||||
return p.cmd.Process.Kill()
|
||||
}
|
||||
|
||||
// Busy is whether the terminal's foreground process group is not the shell's
|
||||
// own: a command it started and is waiting on holds the terminal.
|
||||
func (p *unixPty) Busy() bool {
|
||||
if p.cmd.Process == nil {
|
||||
return false
|
||||
}
|
||||
group, err := unix.IoctlGetInt(int(p.f.Fd()), unix.TIOCGPGRP)
|
||||
return err == nil && group != p.cmd.Process.Pid
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
//go:build windows
|
||||
|
||||
package terminal
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strings"
|
||||
"sync"
|
||||
"syscall"
|
||||
|
||||
"github.com/UserExistsError/conpty"
|
||||
)
|
||||
|
||||
type winPty struct {
|
||||
c *conpty.ConPty
|
||||
once sync.Once
|
||||
}
|
||||
|
||||
func startPty(o Options) (pty, error) {
|
||||
parts := make([]string, 0, len(o.Args)+1)
|
||||
parts = append(parts, syscall.EscapeArg(o.Shell))
|
||||
for _, a := range o.Args {
|
||||
parts = append(parts, syscall.EscapeArg(a))
|
||||
}
|
||||
opts := []conpty.ConPtyOption{conpty.ConPtyDimensions(int(o.Cols), int(o.Rows))}
|
||||
if o.Dir != "" {
|
||||
opts = append(opts, conpty.ConPtyWorkDir(o.Dir))
|
||||
}
|
||||
if len(o.Env) > 0 {
|
||||
opts = append(opts, conpty.ConPtyEnv(o.Env))
|
||||
}
|
||||
c, err := conpty.Start(strings.Join(parts, " "), opts...)
|
||||
if err != nil {
|
||||
return nil, fail(CodeStartFailed, "%v", err)
|
||||
}
|
||||
return &winPty{c: c}, nil
|
||||
}
|
||||
|
||||
func (p *winPty) Read(b []byte) (int, error) { return p.c.Read(b) }
|
||||
func (p *winPty) Write(b []byte) (int, error) { return p.c.Write(b) }
|
||||
func (p *winPty) Close() error {
|
||||
var err error
|
||||
p.once.Do(func() { err = p.c.Close() })
|
||||
return err
|
||||
}
|
||||
func (p *winPty) Resize(cols, rows uint16) error {
|
||||
return p.c.Resize(int(cols), int(rows))
|
||||
}
|
||||
func (p *winPty) Wait() (int, error) {
|
||||
code, err := p.c.Wait(context.Background())
|
||||
return int(code), err
|
||||
}
|
||||
|
||||
// Kill ends the shell by closing its console, which takes its children with it.
|
||||
func (p *winPty) Kill() error { return p.Close() }
|
||||
@@ -0,0 +1,45 @@
|
||||
package terminal
|
||||
|
||||
import "sync"
|
||||
|
||||
// ring keeps the newest bytes a terminal wrote, up to a fixed size, and counts
|
||||
// every byte ever written so a reader can say where it stopped.
|
||||
type ring struct {
|
||||
mu sync.Mutex
|
||||
buf []byte
|
||||
size int
|
||||
total uint64
|
||||
}
|
||||
|
||||
func newRing(size int) *ring { return &ring{size: size} }
|
||||
|
||||
// write appends p and returns the offset just past it.
|
||||
func (r *ring) write(p []byte) uint64 {
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
r.total += uint64(len(p))
|
||||
if len(p) >= r.size {
|
||||
r.buf = append(r.buf[:0], p[len(p)-r.size:]...)
|
||||
return r.total
|
||||
}
|
||||
if over := len(r.buf) + len(p) - r.size; over > 0 {
|
||||
r.buf = append(r.buf[:0], r.buf[over:]...)
|
||||
}
|
||||
r.buf = append(r.buf, p...)
|
||||
return r.total
|
||||
}
|
||||
|
||||
// since returns what was written after offset, and the offset it reaches. A
|
||||
// reader that fell behind the ring's start gets what is still held.
|
||||
func (r *ring) since(offset uint64) ([]byte, uint64) {
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
start := r.total - uint64(len(r.buf))
|
||||
if offset < start {
|
||||
offset = start
|
||||
}
|
||||
if offset > r.total {
|
||||
offset = r.total
|
||||
}
|
||||
return append([]byte(nil), r.buf[offset-start:]...), r.total
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
package terminal
|
||||
|
||||
import (
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// Shell is an interpreter this machine has and a terminal can start.
|
||||
type Shell struct {
|
||||
// Name is what the person sees; Kind is the short tag beside it.
|
||||
Name string `json:"name"`
|
||||
Kind string `json:"kind"`
|
||||
Path string `json:"path"`
|
||||
Args []string `json:"args,omitempty"`
|
||||
}
|
||||
|
||||
// Shells lists the interpreters found on this machine, best first. Only what is
|
||||
// really there is listed: a row for a shell that is missing is a button that
|
||||
// fails.
|
||||
func Shells(getenv func(string) string) []Shell {
|
||||
return shellsOn(runtime.GOOS, getenv, exec.LookPath, fileExists)
|
||||
}
|
||||
|
||||
func fileExists(p string) bool {
|
||||
st, err := os.Stat(p)
|
||||
return err == nil && !st.IsDir()
|
||||
}
|
||||
|
||||
func shellsOn(goos string, getenv func(string) string, look func(string) (string, error), exists func(string) bool) []Shell {
|
||||
var out []Shell
|
||||
seen := map[string]bool{}
|
||||
add := func(name, kind, p string, args ...string) {
|
||||
key := strings.ToLower(p)
|
||||
if p == "" || seen[key] {
|
||||
return
|
||||
}
|
||||
seen[key] = true
|
||||
out = append(out, Shell{Name: name, Kind: kind, Path: p, Args: args})
|
||||
}
|
||||
if goos == "windows" {
|
||||
for _, root := range []string{getenv("ProgramFiles"), getenv("ProgramFiles(x86)"), filepath.Join(getenv("LOCALAPPDATA"), "Programs")} {
|
||||
if root == "" {
|
||||
continue
|
||||
}
|
||||
if p := filepath.Join(root, "Git", "bin", "bash.exe"); exists(p) {
|
||||
add("Git Bash", "bash", p, "--login", "-i")
|
||||
}
|
||||
}
|
||||
if p, err := look("pwsh"); err == nil {
|
||||
add("PowerShell", "pwsh", p, "-NoLogo")
|
||||
}
|
||||
if p, err := look("powershell"); err == nil {
|
||||
add("Windows PowerShell", "powershell", p, "-NoLogo")
|
||||
}
|
||||
if p, err := look("cmd"); err == nil {
|
||||
add("命令提示符", "cmd", p)
|
||||
}
|
||||
if p, err := look("wsl"); err == nil {
|
||||
add("WSL", "wsl", p)
|
||||
}
|
||||
return out
|
||||
}
|
||||
if p := getenv("SHELL"); p != "" && exists(p) {
|
||||
add(filepath.Base(p), filepath.Base(p), p, "-l")
|
||||
}
|
||||
for _, name := range []string{"bash", "zsh", "fish", "sh"} {
|
||||
if p, err := look(name); err == nil {
|
||||
add(name, name, p)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
@@ -0,0 +1,349 @@
|
||||
package terminal
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
"encoding/hex"
|
||||
"os"
|
||||
"sort"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
const (
|
||||
ringSize = 1 << 20
|
||||
subBuffer = 256
|
||||
maxTerminals = 16
|
||||
defaultCols = 80
|
||||
defaultRows = 24
|
||||
maxDim = 1000
|
||||
)
|
||||
|
||||
// Options say how one shell is started. Env is used as given when it is set and
|
||||
// is the process environment otherwise: the caller decides what the person's
|
||||
// shell may inherit.
|
||||
type Options struct {
|
||||
Shell string
|
||||
Args []string
|
||||
Dir string
|
||||
Env []string
|
||||
Cols uint16
|
||||
Rows uint16
|
||||
// Name is what the person sees the shell called.
|
||||
Name string
|
||||
}
|
||||
|
||||
// Info is a terminal as a list shows it.
|
||||
type Info struct {
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Shell string `json:"shell"`
|
||||
Dir string `json:"dir"`
|
||||
Cols int `json:"cols"`
|
||||
Rows int `json:"rows"`
|
||||
Running bool `json:"running"`
|
||||
ExitCode *int `json:"exitCode,omitempty"`
|
||||
// Busy is set while the shell waits on a command it started.
|
||||
Busy bool `json:"busy"`
|
||||
Created time.Time `json:"created"`
|
||||
}
|
||||
|
||||
// Chunk is output a subscriber has not seen.
|
||||
type Chunk []byte
|
||||
|
||||
// Subscription is one reader of a terminal's output.
|
||||
type Subscription struct {
|
||||
// Replay is what the terminal wrote before this subscription began, up to
|
||||
// what the ring still holds.
|
||||
Replay []byte
|
||||
// Output carries what is written afterwards. It closes when the terminal
|
||||
// ends, or when the reader fell too far behind to keep up.
|
||||
Output <-chan Chunk
|
||||
cancel func()
|
||||
}
|
||||
|
||||
// Cancel stops the subscription. It is safe to call more than once.
|
||||
func (s *Subscription) Cancel() { s.cancel() }
|
||||
|
||||
// Session is one running shell.
|
||||
type Session struct {
|
||||
id string
|
||||
opts Options
|
||||
created time.Time
|
||||
out *ring
|
||||
|
||||
mu sync.Mutex
|
||||
pty pty
|
||||
cols uint16
|
||||
rows uint16
|
||||
subs map[int]chan Chunk
|
||||
nextID int
|
||||
exited bool
|
||||
code int
|
||||
done chan struct{}
|
||||
}
|
||||
|
||||
// Manager keeps the terminals of one host.
|
||||
type Manager struct {
|
||||
mu sync.Mutex
|
||||
sessions map[string]*Session
|
||||
}
|
||||
|
||||
// NewManager returns an empty Manager.
|
||||
func NewManager() *Manager { return &Manager{sessions: map[string]*Session{}} }
|
||||
|
||||
func clamp(v uint16, fallback uint16) uint16 {
|
||||
if v == 0 {
|
||||
return fallback
|
||||
}
|
||||
if v > maxDim {
|
||||
return maxDim
|
||||
}
|
||||
return v
|
||||
}
|
||||
|
||||
func newID() string {
|
||||
var b [8]byte
|
||||
_, _ = rand.Read(b[:])
|
||||
return hex.EncodeToString(b[:])
|
||||
}
|
||||
|
||||
// Open starts a shell and returns its Session.
|
||||
func (m *Manager) Open(o Options) (*Session, error) {
|
||||
if o.Shell == "" {
|
||||
return nil, fail(CodeShellMissing, "no shell was given")
|
||||
}
|
||||
o.Cols, o.Rows = clamp(o.Cols, defaultCols), clamp(o.Rows, defaultRows)
|
||||
if o.Dir != "" {
|
||||
if st, err := os.Stat(o.Dir); err != nil || !st.IsDir() {
|
||||
return nil, fail(CodeStartFailed, "%s is not a folder", o.Dir)
|
||||
}
|
||||
}
|
||||
m.mu.Lock()
|
||||
if len(m.sessions) >= maxTerminals {
|
||||
m.mu.Unlock()
|
||||
return nil, fail(CodeLimit, "at most %d terminals are open", maxTerminals)
|
||||
}
|
||||
m.mu.Unlock()
|
||||
s := &Session{id: newID(), opts: o, created: time.Now(), out: newRing(ringSize), subs: map[int]chan Chunk{}, cols: o.Cols, rows: o.Rows}
|
||||
if err := s.start(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
m.mu.Lock()
|
||||
m.sessions[s.id] = s
|
||||
m.mu.Unlock()
|
||||
return s, nil
|
||||
}
|
||||
|
||||
// Get returns a terminal by id.
|
||||
func (m *Manager) Get(id string) (*Session, error) {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
if s, ok := m.sessions[id]; ok {
|
||||
return s, nil
|
||||
}
|
||||
return nil, fail(CodeNotFound, "no terminal %s", id)
|
||||
}
|
||||
|
||||
// List is every open terminal, oldest first.
|
||||
func (m *Manager) List() []Info {
|
||||
m.mu.Lock()
|
||||
all := make([]*Session, 0, len(m.sessions))
|
||||
for _, s := range m.sessions {
|
||||
all = append(all, s)
|
||||
}
|
||||
m.mu.Unlock()
|
||||
sort.Slice(all, func(i, j int) bool { return all[i].created.Before(all[j].created) })
|
||||
out := make([]Info, 0, len(all))
|
||||
for _, s := range all {
|
||||
out = append(out, s.Info())
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// Close ends a terminal and forgets it.
|
||||
func (m *Manager) Close(id string) error {
|
||||
m.mu.Lock()
|
||||
s, ok := m.sessions[id]
|
||||
delete(m.sessions, id)
|
||||
m.mu.Unlock()
|
||||
if !ok {
|
||||
return fail(CodeNotFound, "no terminal %s", id)
|
||||
}
|
||||
s.shutdown()
|
||||
return nil
|
||||
}
|
||||
|
||||
// CloseAll ends every terminal; the host calls it as it exits.
|
||||
func (m *Manager) CloseAll() {
|
||||
m.mu.Lock()
|
||||
all := m.sessions
|
||||
m.sessions = map[string]*Session{}
|
||||
m.mu.Unlock()
|
||||
for _, s := range all {
|
||||
s.shutdown()
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Session) start() error {
|
||||
o := s.opts
|
||||
o.Cols, o.Rows = s.cols, s.rows
|
||||
p, err := startPty(o)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
s.mu.Lock()
|
||||
s.pty, s.exited, s.done = p, false, make(chan struct{})
|
||||
done := s.done
|
||||
s.mu.Unlock()
|
||||
go s.pump(p, done)
|
||||
return nil
|
||||
}
|
||||
|
||||
// flushGrace is how long the last of a shell's output is given to arrive after
|
||||
// the shell has ended, before the console is closed to end the read.
|
||||
const flushGrace = 300 * time.Millisecond
|
||||
|
||||
// pump copies the shell's output to the ring and the subscribers until it ends.
|
||||
// Reading does not end by itself on every platform when the shell does, so the
|
||||
// shell's end is waited for on its own and closing the console is what stops the read.
|
||||
func (s *Session) pump(p pty, done chan struct{}) {
|
||||
readDone := make(chan struct{})
|
||||
go func() {
|
||||
defer close(readDone)
|
||||
buf := make([]byte, 32*1024)
|
||||
for {
|
||||
n, err := p.Read(buf)
|
||||
if n > 0 {
|
||||
chunk := append(Chunk(nil), buf[:n]...)
|
||||
s.mu.Lock()
|
||||
s.out.write(chunk)
|
||||
for id, ch := range s.subs {
|
||||
select {
|
||||
case ch <- chunk:
|
||||
default:
|
||||
close(ch)
|
||||
delete(s.subs, id)
|
||||
}
|
||||
}
|
||||
s.mu.Unlock()
|
||||
}
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
}
|
||||
}()
|
||||
code, _ := p.Wait()
|
||||
select {
|
||||
case <-readDone:
|
||||
case <-time.After(flushGrace):
|
||||
}
|
||||
_ = p.Close()
|
||||
<-readDone
|
||||
s.mu.Lock()
|
||||
s.exited, s.code = true, code
|
||||
for id, ch := range s.subs {
|
||||
close(ch)
|
||||
delete(s.subs, id)
|
||||
}
|
||||
s.mu.Unlock()
|
||||
close(done)
|
||||
}
|
||||
|
||||
// ID is the terminal's identity.
|
||||
func (s *Session) ID() string { return s.id }
|
||||
|
||||
// Info is the terminal as a list shows it.
|
||||
func (s *Session) Info() Info {
|
||||
s.mu.Lock()
|
||||
pt, exited := s.pty, s.exited
|
||||
s.mu.Unlock()
|
||||
busy := !exited && pt != nil && pt.Busy()
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
info := Info{Busy: busy, ID: s.id, Name: s.opts.Name, Shell: s.opts.Shell, Dir: s.opts.Dir, Cols: int(s.cols), Rows: int(s.rows), Running: !s.exited, Created: s.created}
|
||||
if s.exited {
|
||||
c := s.code
|
||||
info.ExitCode = &c
|
||||
}
|
||||
return info
|
||||
}
|
||||
|
||||
// Write sends the person's keystrokes to the shell.
|
||||
func (s *Session) Write(p []byte) error {
|
||||
s.mu.Lock()
|
||||
pt, exited := s.pty, s.exited
|
||||
s.mu.Unlock()
|
||||
if exited {
|
||||
return fail(CodeExited, "the shell has ended")
|
||||
}
|
||||
_, err := pt.Write(p)
|
||||
return err
|
||||
}
|
||||
|
||||
// Resize tells the shell its window changed size.
|
||||
func (s *Session) Resize(cols, rows uint16) error {
|
||||
cols, rows = clamp(cols, defaultCols), clamp(rows, defaultRows)
|
||||
s.mu.Lock()
|
||||
pt, exited := s.pty, s.exited
|
||||
s.cols, s.rows = cols, rows
|
||||
s.mu.Unlock()
|
||||
if exited {
|
||||
return nil
|
||||
}
|
||||
return pt.Resize(cols, rows)
|
||||
}
|
||||
|
||||
// Subscribe starts reading the terminal's output: what it held, then what comes.
|
||||
func (s *Session) Subscribe() *Subscription {
|
||||
ch := make(chan Chunk, subBuffer)
|
||||
s.mu.Lock()
|
||||
replay, _ := s.out.since(0)
|
||||
id := s.nextID
|
||||
s.nextID++
|
||||
if s.exited {
|
||||
close(ch)
|
||||
} else {
|
||||
s.subs[id] = ch
|
||||
}
|
||||
s.mu.Unlock()
|
||||
return &Subscription{Replay: replay, Output: ch, cancel: func() {
|
||||
s.mu.Lock()
|
||||
if c, ok := s.subs[id]; ok {
|
||||
close(c)
|
||||
delete(s.subs, id)
|
||||
}
|
||||
s.mu.Unlock()
|
||||
}}
|
||||
}
|
||||
|
||||
// Restart starts the shell again in this terminal once it has ended.
|
||||
func (s *Session) Restart() error {
|
||||
s.mu.Lock()
|
||||
running := !s.exited
|
||||
s.mu.Unlock()
|
||||
if running {
|
||||
return nil
|
||||
}
|
||||
return s.start()
|
||||
}
|
||||
|
||||
// Done closes when the current shell has ended.
|
||||
func (s *Session) Done() <-chan struct{} {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
return s.done
|
||||
}
|
||||
|
||||
func (s *Session) shutdown() {
|
||||
s.mu.Lock()
|
||||
pt, exited, done := s.pty, s.exited, s.done
|
||||
s.mu.Unlock()
|
||||
if exited || pt == nil {
|
||||
return
|
||||
}
|
||||
_ = pt.Kill()
|
||||
select {
|
||||
case <-done:
|
||||
case <-time.After(2 * time.Second):
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,265 @@
|
||||
package terminal
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"errors"
|
||||
"runtime"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
// oneShot is a shell that runs a single command and ends, with its exit code.
|
||||
func oneShot(script string) Options {
|
||||
if runtime.GOOS == "windows" {
|
||||
return Options{Shell: "cmd.exe", Args: []string{"/c", script}, Name: "cmd"}
|
||||
}
|
||||
return Options{Shell: "/bin/sh", Args: []string{"-c", script}, Name: "sh"}
|
||||
}
|
||||
|
||||
func drain(t *testing.T, s *Session) string {
|
||||
t.Helper()
|
||||
sub := s.Subscribe()
|
||||
defer sub.Cancel()
|
||||
var out bytes.Buffer
|
||||
out.Write(sub.Replay)
|
||||
timeout := time.After(15 * time.Second)
|
||||
for {
|
||||
select {
|
||||
case c, ok := <-sub.Output:
|
||||
if !ok {
|
||||
return out.String()
|
||||
}
|
||||
out.Write(c)
|
||||
case <-timeout:
|
||||
t.Fatalf("the shell did not end; output so far: %q", out.String())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestRingKeepsTheNewestBytesAndCountsAll(t *testing.T) {
|
||||
r := newRing(8)
|
||||
r.write([]byte("abcd"))
|
||||
r.write([]byte("efghij"))
|
||||
got, at := r.since(0)
|
||||
if string(got) != "cdefghij" || at != 10 {
|
||||
t.Fatalf("since(0) = %q at %d, want %q at 10", got, at, "cdefghij")
|
||||
}
|
||||
got, _ = r.since(7)
|
||||
if string(got) != "hij" {
|
||||
t.Fatalf("since(7) = %q, want hij", got)
|
||||
}
|
||||
r.write(bytes.Repeat([]byte("z"), 20))
|
||||
if got, _ = r.since(0); len(got) != 8 {
|
||||
t.Fatalf("a write larger than the ring keeps %d bytes, want 8", len(got))
|
||||
}
|
||||
}
|
||||
|
||||
func TestAShellsOutputReachesASubscriberAndItsExitCodeIsKept(t *testing.T) {
|
||||
m := NewManager()
|
||||
defer m.CloseAll()
|
||||
s, err := m.Open(oneShot("echo probe-xyz"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if out := drain(t, s); !strings.Contains(out, "probe-xyz") {
|
||||
t.Fatalf("output %q lacks the echoed text", out)
|
||||
}
|
||||
<-s.Done()
|
||||
info := s.Info()
|
||||
if info.Running || info.ExitCode == nil || *info.ExitCode != 0 {
|
||||
t.Fatalf("info after a clean exit = %+v", info)
|
||||
}
|
||||
}
|
||||
|
||||
func TestANonzeroExitCodeIsReportedAsItWas(t *testing.T) {
|
||||
m := NewManager()
|
||||
defer m.CloseAll()
|
||||
s, err := m.Open(oneShot("exit 3"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
drain(t, s)
|
||||
<-s.Done()
|
||||
if c := s.Info().ExitCode; c == nil || *c != 3 {
|
||||
t.Fatalf("exit code = %v, want 3", c)
|
||||
}
|
||||
}
|
||||
|
||||
func TestALateSubscriberReplaysWhatWasMissed(t *testing.T) {
|
||||
m := NewManager()
|
||||
defer m.CloseAll()
|
||||
s, err := m.Open(oneShot("echo late-replay"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
<-s.Done()
|
||||
sub := s.Subscribe()
|
||||
defer sub.Cancel()
|
||||
if !strings.Contains(string(sub.Replay), "late-replay") {
|
||||
t.Fatalf("replay %q lacks the output written before subscribing", sub.Replay)
|
||||
}
|
||||
if _, open := <-sub.Output; open {
|
||||
t.Fatal("a subscription to an ended terminal must be closed")
|
||||
}
|
||||
}
|
||||
|
||||
func TestWritingToAnEndedShellSaysSoByCode(t *testing.T) {
|
||||
m := NewManager()
|
||||
defer m.CloseAll()
|
||||
s, err := m.Open(oneShot("exit 0"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
<-s.Done()
|
||||
if err := s.Write([]byte("x")); CodeOf(err) != CodeExited {
|
||||
t.Fatalf("write error code = %q, want %q", CodeOf(err), CodeExited)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAnInteractiveShellTakesKeystrokes(t *testing.T) {
|
||||
m := NewManager()
|
||||
defer m.CloseAll()
|
||||
o := Options{Shell: "/bin/sh", Name: "sh"}
|
||||
input := "echo typed-line\nexit\n"
|
||||
if runtime.GOOS == "windows" {
|
||||
o = Options{Shell: "cmd.exe", Name: "cmd"}
|
||||
input = "echo typed-line\r\nexit\r\n"
|
||||
}
|
||||
s, err := m.Open(o)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := s.Write([]byte(input)); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if out := drain(t, s); !strings.Contains(out, "typed-line") {
|
||||
t.Fatalf("output %q lacks what was typed", out)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRestartStartsTheShellAgainOnlyOnceItHasEnded(t *testing.T) {
|
||||
m := NewManager()
|
||||
defer m.CloseAll()
|
||||
s, err := m.Open(oneShot("echo first"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
<-s.Done()
|
||||
if err := s.Restart(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if out := drain(t, s); !strings.Contains(out, "first") {
|
||||
t.Fatalf("restarted output %q", out)
|
||||
}
|
||||
<-s.Done()
|
||||
if s.Info().Running {
|
||||
t.Fatal("the restarted shell should have ended too")
|
||||
}
|
||||
}
|
||||
|
||||
func TestOpenRefusesWithCodes(t *testing.T) {
|
||||
m := NewManager()
|
||||
defer m.CloseAll()
|
||||
if _, err := m.Open(Options{}); CodeOf(err) != CodeShellMissing {
|
||||
t.Fatalf("no shell: code %q", CodeOf(err))
|
||||
}
|
||||
if _, err := m.Open(Options{Shell: "x", Dir: "Z:/no/such/folder"}); CodeOf(err) != CodeStartFailed {
|
||||
t.Fatalf("missing folder: code %q", CodeOf(err))
|
||||
}
|
||||
if _, err := m.Get("nope"); CodeOf(err) != CodeNotFound {
|
||||
t.Fatalf("unknown id: code %q", CodeOf(err))
|
||||
}
|
||||
if err := m.Close("nope"); CodeOf(err) != CodeNotFound {
|
||||
t.Fatalf("close unknown: code %q", CodeOf(err))
|
||||
}
|
||||
var e *Error
|
||||
if !errors.As(&Error{Code: CodeLimit}, &e) {
|
||||
t.Fatal("Error must be matchable with errors.As")
|
||||
}
|
||||
}
|
||||
|
||||
func TestShellsListsOnlyWhatIsThere(t *testing.T) {
|
||||
look := func(name string) (string, error) {
|
||||
if name == "pwsh" || name == "cmd" {
|
||||
return `C:\bin\` + name + ".exe", nil
|
||||
}
|
||||
return "", errors.New("missing")
|
||||
}
|
||||
exists := func(p string) bool {
|
||||
return strings.HasSuffix(p, `Git\bin\bash.exe`) && strings.HasPrefix(p, `C:\Program Files`)
|
||||
}
|
||||
env := func(k string) string {
|
||||
if k == "ProgramFiles" {
|
||||
return `C:\Program Files`
|
||||
}
|
||||
return ""
|
||||
}
|
||||
got := shellsOn("windows", env, look, exists)
|
||||
var names []string
|
||||
for _, s := range got {
|
||||
names = append(names, s.Kind)
|
||||
}
|
||||
if strings.Join(names, ",") != "bash,pwsh,cmd" {
|
||||
t.Fatalf("shells = %v, want bash,pwsh,cmd (best first, nothing absent)", names)
|
||||
}
|
||||
}
|
||||
|
||||
func TestShellsOnUnixStartsWithThePersonsOwn(t *testing.T) {
|
||||
look := func(name string) (string, error) { return "/usr/bin/" + name, nil }
|
||||
got := shellsOn("linux", func(k string) string {
|
||||
if k == "SHELL" {
|
||||
return "/usr/bin/zsh"
|
||||
}
|
||||
return ""
|
||||
}, look, func(string) bool { return true })
|
||||
if len(got) == 0 || got[0].Kind != "zsh" {
|
||||
t.Fatalf("shells = %+v, want the SHELL one first", got)
|
||||
}
|
||||
for i := range got {
|
||||
for j := i + 1; j < len(got); j++ {
|
||||
if got[i].Path == got[j].Path {
|
||||
t.Fatalf("%s listed twice", got[i].Path)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestPersonEnvLeavesTheHostsOwnSecretsBehind(t *testing.T) {
|
||||
got := PersonEnv([]string{"PATH=/bin", "DEEPSEEK_API_KEY=k", "REASONIX_HOME=/h", "reasonix_token=t", "HOME=/u"})
|
||||
if strings.Join(got, ",") != "PATH=/bin,HOME=/u" {
|
||||
t.Fatalf("env = %v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBusyIsTrueWhileTheShellWaitsOnACommandItStarted(t *testing.T) {
|
||||
m := NewManager()
|
||||
defer m.CloseAll()
|
||||
o, wait := Options{Shell: "/bin/sh", Name: "sh"}, "sleep 3\n"
|
||||
if runtime.GOOS == "windows" {
|
||||
o, wait = Options{Shell: "cmd.exe", Name: "cmd"}, "ping -n 4 127.0.0.1 >nul\r\n"
|
||||
}
|
||||
s, err := m.Open(o)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if s.Info().Busy {
|
||||
t.Fatal("an idle shell reported busy")
|
||||
}
|
||||
if err := s.Write([]byte(wait)); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
deadline := time.Now().Add(5 * time.Second)
|
||||
for !s.Info().Busy {
|
||||
if time.Now().After(deadline) {
|
||||
t.Fatal("a shell running a command never reported busy")
|
||||
}
|
||||
time.Sleep(50 * time.Millisecond)
|
||||
}
|
||||
for s.Info().Busy {
|
||||
if time.Now().After(deadline.Add(10 * time.Second)) {
|
||||
t.Fatal("busy never cleared after the command ended")
|
||||
}
|
||||
time.Sleep(100 * time.Millisecond)
|
||||
}
|
||||
}
|
||||
@@ -58,6 +58,7 @@ var leaves = []string{
|
||||
"internal/safety/egress",
|
||||
"internal/safety/redirectguard",
|
||||
"internal/platform/releaseasset",
|
||||
"internal/platform/terminal",
|
||||
"internal/platform/wsl",
|
||||
"internal/base/retrieval",
|
||||
"internal/base/rulelist",
|
||||
|
||||
@@ -30,6 +30,7 @@ const (
|
||||
tsProviderFile = "desktop/frontend-next/src/port/provider.ts"
|
||||
tsFeedbackFile = "desktop/frontend-next/src/port/feedback.ts"
|
||||
tsWorkspaceFile = "desktop/frontend-next/src/port/workspace.ts"
|
||||
tsTerminalFile = "desktop/frontend-next/src/port/terminal.ts"
|
||||
tsLookFile = "desktop/frontend-next/src/port/look.ts"
|
||||
tsHubFile = "desktop/frontend-next/src/port/hub.ts"
|
||||
tsMuxFile = "desktop/frontend-next/src/port/mux.ts"
|
||||
@@ -312,6 +313,9 @@ var mirroredWireTypesHead = []wireMirror{
|
||||
{"internal/platform/gitpush/plan.go", "Plan", tsWorkspaceFile, "PushPlan"},
|
||||
{"internal/platform/gitpush/push.go", "Result", tsWorkspaceFile, "PushResult"},
|
||||
{"internal/frontend/serve/changes.go", "gitRepoRow", tsWorkspaceFile, "GitRepoRow"},
|
||||
{"internal/platform/terminal/terminal.go", "Info", tsTerminalFile, "TerminalInfo"},
|
||||
{"internal/platform/terminal/shells.go", "Shell", tsTerminalFile, "ShellChoice"},
|
||||
{"internal/frontend/serve/terminals.go", "terminalFrame", tsTerminalFile, "TerminalFrame"},
|
||||
{"internal/frontend/serve/changes.go", "gitReposView", tsWorkspaceFile, "GitRepos"},
|
||||
{"internal/platform/gitstatus/commitdetail.go", "CommitFile", tsWorkspaceFile, "GitCommitFile"},
|
||||
{"internal/frontend/serve/git_commit_detail.go", "commitFilesView", tsWorkspaceFile, "GitCommitFiles"},
|
||||
|
||||
Reference in new issue
Block a user