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:
yhh committed 2026-11-06 04:44:41 -08:00
1 parent 3cf430d823
commit 83bdeb81f0
60 files changed
+2748 -48

No files matched your search

+5 -2
View File
@@ -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",
+24
View File
@@ -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); }
+5
View File
@@ -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",
+2 -1
View File
@@ -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);
+2 -1
View File
@@ -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 -2
View File
@@ -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;
}
+10 -1
View File
@@ -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} />)}</>;
}
+3 -2
View File
@@ -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>
+18 -17
View File
@@ -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;
}
+2
View File
@@ -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
+5
View File
@@ -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=
+16
View File
@@ -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()
}
+8
View File
@@ -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
+1
View File
@@ -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()
}
+1 -1
View File
@@ -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)
+3 -16
View File
@@ -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.
//
+226
View File
@@ -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
}
}
}
+155
View File
@@ -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")
}
}
+13
View File
@@ -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
+19
View File
@@ -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
}
+36
View File
@@ -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 ""
}
+14
View File
@@ -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
}
+72
View File
@@ -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
}
+55
View File
@@ -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() }
+45
View File
@@ -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
}
+75
View File
@@ -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
}
+349
View File
@@ -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):
}
}
+265
View File
@@ -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)
}
}
+1
View File
@@ -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",
+4
View File
@@ -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"},