From 1bf533488c83531ff5767d1bc8be5ecacc954c6d Mon Sep 17 00:00:00 2001 From: iamnbutler Date: Thu, 22 Oct 2026 18:19:50 -0400 Subject: [PATCH 1/2] fix(app): ask before multi-line terminal pastes and archiving running terminals; let Cmd+K open the palette Why: apps/app/src/panels/terminal.tsx#function Terminal | without bracketed paste the shell runs each pasted line, so a multi-line paste must confirm first; Cmd/Ctrl+K is left to the window's palette, not cleared Why: apps/host/src/terminals.ts#function list | a shell counts as running a command only when its terminal's foreground group isn't the shell's own; prompt helpers that are children of the shell must not count --- ...MR94GF5184-the-terminal-s-cmd-k-clear-m.md | 0 ...F4P375REE7-all-projects-and-project-vie.md | 6 +- ...GVV571Y6BJ-the-project-list-is-built-on.md | 6 +- ...21HV0XVQQXV-without-bracketed-paste-the.md | 8 +++ ...P7JWDSEEWZM-a-shell-counts-as-running-a.md | 8 +++ apps/app/src/app.tsx | 34 ++++++++-- apps/app/src/archive-channel.tsx | 45 +++++++++++++ apps/app/src/palette.tsx | 2 +- apps/app/src/panels/terminal.tsx | 63 ++++++++++++++++--- apps/host/src/protocol.ts | 9 ++- apps/host/src/terminals.ts | 51 +++++++++++---- 11 files changed, 200 insertions(+), 32 deletions(-) rename .lane/{memory => attic}/apps/app/src/panels/terminal.tsx/01M4YSG7VPSWA76RMR94GF5184-the-terminal-s-cmd-k-clear-m.md (100%) create mode 100644 .lane/memory/apps/app/src/panels/terminal.tsx/01M5JV5YSFRZMQZ21HV0XVQQXV-without-bracketed-paste-the.md create mode 100644 .lane/memory/apps/host/src/terminals.ts/01M5JV5YV1G8KW4P7JWDSEEWZM-a-shell-counts-as-running-a.md create mode 100644 apps/app/src/archive-channel.tsx diff --git a/.lane/memory/apps/app/src/panels/terminal.tsx/01M4YSG7VPSWA76RMR94GF5184-the-terminal-s-cmd-k-clear-m.md b/.lane/attic/apps/app/src/panels/terminal.tsx/01M4YSG7VPSWA76RMR94GF5184-the-terminal-s-cmd-k-clear-m.md similarity index 100% rename from .lane/memory/apps/app/src/panels/terminal.tsx/01M4YSG7VPSWA76RMR94GF5184-the-terminal-s-cmd-k-clear-m.md rename to .lane/attic/apps/app/src/panels/terminal.tsx/01M4YSG7VPSWA76RMR94GF5184-the-terminal-s-cmd-k-clear-m.md diff --git a/.lane/memory/apps/app/src/app.tsx/01M4YT6XB7NQ89E6F4P375REE7-all-projects-and-project-vie.md b/.lane/memory/apps/app/src/app.tsx/01M4YT6XB7NQ89E6F4P375REE7-all-projects-and-project-vie.md index d84bcf82..2a95f677 100644 --- a/.lane/memory/apps/app/src/app.tsx/01M4YT6XB7NQ89E6F4P375REE7-all-projects-and-project-vie.md +++ b/.lane/memory/apps/app/src/app.tsx/01M4YT6XB7NQ89E6F4P375REE7-all-projects-and-project-vie.md @@ -4,9 +4,9 @@ anchor: function App created: 2026-10-15T03:38:04Z norm: '1' sig: 063d0f00fea02511 -body_hash: b43eff10846d0cd3 -raw_hash: c90b02e06b07e972 -vouched: 2026-10-22T22:08:11Z +body_hash: 3414bb6abb49dfc4 +raw_hash: c619f3850424f109 +vouched: 2026-10-22T22:19:55Z lines: 235-824 --- diff --git a/.lane/memory/apps/app/src/app.tsx/01M5JMVG9G87F31FGVV571Y6BJ-the-project-list-is-built-on.md b/.lane/memory/apps/app/src/app.tsx/01M5JMVG9G87F31FGVV571Y6BJ-the-project-list-is-built-on.md index 84a56e07..93cdb985 100644 --- a/.lane/memory/apps/app/src/app.tsx/01M5JMVG9G87F31FGVV571Y6BJ-the-project-list-is-built-on.md +++ b/.lane/memory/apps/app/src/app.tsx/01M5JMVG9G87F31FGVV571Y6BJ-the-project-list-is-built-on.md @@ -4,9 +4,9 @@ anchor: function App created: 2026-10-22T20:29:16Z norm: '1' sig: 063d0f00fea02511 -body_hash: b43eff10846d0cd3 -raw_hash: c90b02e06b07e972 -vouched: 2026-10-22T22:08:11Z +body_hash: 3414bb6abb49dfc4 +raw_hash: c619f3850424f109 +vouched: 2026-10-22T22:19:55Z lines: 330-1237 --- diff --git a/.lane/memory/apps/app/src/panels/terminal.tsx/01M5JV5YSFRZMQZ21HV0XVQQXV-without-bracketed-paste-the.md b/.lane/memory/apps/app/src/panels/terminal.tsx/01M5JV5YSFRZMQZ21HV0XVQQXV-without-bracketed-paste-the.md new file mode 100644 index 00000000..81238a58 --- /dev/null +++ b/.lane/memory/apps/app/src/panels/terminal.tsx/01M5JV5YSFRZMQZ21HV0XVQQXV-without-bracketed-paste-the.md @@ -0,0 +1,8 @@ +--- +id: 01M5JV5YSFRZMQZ21HV0XVQQXV +anchor: function Terminal +created: 2026-10-22T22:19:50Z +norm: '1' +--- + +without bracketed paste the shell runs each pasted line, so a multi-line paste must confirm first; Cmd/Ctrl+K is left to the window's palette, not cleared diff --git a/.lane/memory/apps/host/src/terminals.ts/01M5JV5YV1G8KW4P7JWDSEEWZM-a-shell-counts-as-running-a.md b/.lane/memory/apps/host/src/terminals.ts/01M5JV5YV1G8KW4P7JWDSEEWZM-a-shell-counts-as-running-a.md new file mode 100644 index 00000000..0b621e86 --- /dev/null +++ b/.lane/memory/apps/host/src/terminals.ts/01M5JV5YV1G8KW4P7JWDSEEWZM-a-shell-counts-as-running-a.md @@ -0,0 +1,8 @@ +--- +id: 01M5JV5YV1G8KW4P7JWDSEEWZM +anchor: function list +created: 2026-10-22T22:19:50Z +norm: '1' +--- + +a shell counts as running a command only when its terminal's foreground group isn't the shell's own; prompt helpers that are children of the shell must not count diff --git a/apps/app/src/app.tsx b/apps/app/src/app.tsx index b814c311..dcd5701a 100644 --- a/apps/app/src/app.tsx +++ b/apps/app/src/app.tsx @@ -32,10 +32,12 @@ import { type People, type Project, projectId, + type TerminalShell, } from "@ace/host/protocol"; import { chosen, deployed, gateway, remember } from "./address"; import { type Attention, glow, useAttention } from "./attention"; +import { ArchiveChannel } from "./archive-channel"; import { Channel, ChannelDetails, type ChannelDraft } from "./channel"; import { useCompute } from "./compute"; import { Dashboard } from "./dashboard"; @@ -355,6 +357,7 @@ export function App() { const [draft, setDraft] = useState(); const [renaming, setRenaming] = useState<{ id: string; name: string }>(); const [deleting, setDeleting] = useState<{ id: string; name: string }>(); + const [archiving, setArchiving] = useState<{ id: string; name: string; commands: string[] }>(); const [sharing, setSharing] = useState(false); const [removing, setRemoving] = useState(); const [askRemove, setAskRemove] = useLocalStorage("ace:remove-project-ask", true); @@ -842,6 +845,18 @@ export function App() { } } + async function archive(id: string, name: string) { + let shells: TerminalShell[]; + try { + shells = await host.request({ op: "terminals", channel: id }); + } catch (error) { + return toast.error("Could not update channel", { description: (error as Error).message }); + } + const commands = shells.flatMap((shell) => shell.command ? [shell.command] : []); + if (commands.length) return setArchiving({ id, name, commands }); + await change({ op: "archive", channel: id, archived: true }); + } + async function archiveInactive(project: string) { try { const count = await host.request({ op: "archive-inactive", project }); @@ -977,11 +992,9 @@ export function App() { setCollapsed((value) => ({ ...value, [id]: !folded(value, id) }))} onArchive={(allProjects || local) && connected ? (item) => - void change({ - op: "archive", - channel: item.uid, - archived: item.lifecycle !== "archived", - }) + void (item.lifecycle === "archived" + ? change({ op: "archive", channel: item.uid, archived: false }) + : archive(item.uid, item.name)) : undefined} onArchiveInactive={!allProjects && local && connected ? () => void archiveInactive(current.path) @@ -1222,6 +1235,17 @@ export function App() { onCancel={() => setRemoving(undefined)} /> )} + {archiving && ( + { + setArchiving(undefined); + void change({ op: "archive", channel: archiving.id, archived: true }); + }} + onCancel={() => setArchiving(undefined)} + /> + )} {deleting && ( void; + onCancel: () => void; +}; + +/** Archiving hangs up the channel's terminals, so it asks first while one is running a command. */ +export function ArchiveChannel({ name, commands, onArchive, onCancel }: Props) { + const list = [...new Set(commands)].join(", "); + const count = commands.length === 1 ? "A terminal is" : `${commands.length} terminals are`; + return ( + next || onCancel()}> + + + Archive #{name}? + + {count} still running {list}. Archiving ends {commands.length === 1 + ? "it" + : "them"}. + + + + + + + + + ); +} diff --git a/apps/app/src/palette.tsx b/apps/app/src/palette.tsx index ad52ebbe..ebf1a963 100644 --- a/apps/app/src/palette.tsx +++ b/apps/app/src/palette.tsx @@ -109,7 +109,7 @@ export function Palette( useEffect(() => { function key(event: KeyboardEvent) { if ((!event.metaKey && !event.ctrlKey) || event.altKey || event.shiftKey) return; - // The composer links and the terminal clears with the same keys while they have focus. + // The composer links with the same keys while it has focus. if (event.key.toLowerCase() !== "k" || event.defaultPrevented) return; event.preventDefault(); const state = switcher.current?.window(); diff --git a/apps/app/src/panels/terminal.tsx b/apps/app/src/panels/terminal.tsx index cd645187..a06756db 100644 --- a/apps/app/src/panels/terminal.tsx +++ b/apps/app/src/panels/terminal.tsx @@ -10,7 +10,16 @@ import { FitAddon } from "@xterm/addon-fit"; import { type ITheme, Terminal as Xterm } from "@xterm/xterm"; import "@xterm/xterm/css/xterm.css"; -import { useResolvedTheme } from "@ace/ui"; +import { + Button, + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, + useResolvedTheme, +} from "@ace/ui"; import type { TerminalOpened } from "@ace/host/protocol"; import { host, onOpen } from "../host"; @@ -55,6 +64,8 @@ const FALLBACK = { brightWhite: "rgb(31 35 40)", } satisfies ITheme; +const lines = (text: string) => text.trimEnd().split("\n").length; + let canvas: HTMLCanvasElement | undefined; // xterm needs concrete colors, so CSS tokens (oklch, color-mix) are resolved through a canvas. @@ -130,6 +141,7 @@ export function Terminal({ channel, chat, active, terminal, onTerminal }: Props) const replaying = useRef(0); const [node, setNode] = useState(null); const [state, setState] = useState({ status: "idle" }); + const [pasting, setPasting] = useState(); const status = useSyncExternalStore(host.subscribe, () => host.status); const scheme = useResolvedTheme(); const schemeRef = useRef(scheme); @@ -161,14 +173,19 @@ export function Terminal({ channel, chat, active, terminal, onTerminal }: Props) fit.current = addon; replaying.current = 0; - term.attachCustomKeyEventHandler((event) => { - if (event.type !== "keydown" || event.key !== "k") return true; - if (!(MAC ? event.metaKey : event.ctrlKey)) return true; - // Claims the key from the window's command palette shortcut. + // Left to the window, so the command palette opens from a focused terminal too. + term.attachCustomKeyEventHandler((event) => + event.key !== "k" || !(MAC ? event.metaKey : event.ctrlKey) + ); + // Without bracketed paste the shell runs each pasted line as it arrives, so several lines ask first. + const paste = (event: ClipboardEvent) => { + const text = event.clipboardData?.getData("text/plain") || ""; + if (term.modes.bracketedPasteMode || !text.trimEnd().includes("\n")) return; event.preventDefault(); - term.clear(); - return false; - }); + event.stopImmediatePropagation(); + setPasting(text); + }; + node.addEventListener("paste", paste, true); const input = term.onData((data) => { // xterm answers queries in replayed output (cursor position, colors) as if they were live, // and the shell would read those answers as typed input. @@ -184,6 +201,7 @@ export function Terminal({ channel, chat, active, terminal, onTerminal }: Props) observer.observe(node); return () => { + node.removeEventListener("paste", paste, true); observer.disconnect(); input.dispose(); size.dispose(); @@ -290,6 +308,35 @@ export function Terminal({ channel, chat, active, terminal, onTerminal }: Props) {notice} )} + {pasting !== undefined && ( + next || setPasting(undefined)} + > + + + Paste {lines(pasting)} lines? + + The shell runs each line as a command as soon as it's pasted. + + + + + + + + + )}
0; - shells.push({ terminal: terminal.id, chat: terminal.chat, cwd: terminal.cwd, attached }); +/** Each process's terminal foreground group and command, by pid. */ +async function processes(): Promise> { + const ps = Bun.spawn(["ps", "-axo", "pid=,tpgid=,comm="], { stdout: "pipe", stderr: "ignore" }); + const table = new Map(); + for (const line of (await new Response(ps.stdout).text()).split("\n")) { + const match = /^\s*(\d+)\s+(-?\d+)\s+(.+)$/.exec(line); + if (match) { + table.set(Number(match[1]), { + foreground: Number(match[2]), + command: match[3]!.split("/").at(-1)!, + }); + } } - return shells; + return table; +} + +/** A channel's running shells, including those no tab shows any more, with any command in progress. */ +export async function list(channel: string): Promise { + const owned = [...terminals.values()].filter((terminal) => terminal.channel === channel); + if (!owned.length) return []; + const table = await processes(); + return owned.map((terminal) => { + // The shell leads its own process group, so another foreground group is a command it started. + const foreground = table.get(terminal.process.pid)?.foreground; + const command = foreground && foreground !== terminal.process.pid + ? table.get(foreground)?.command + : undefined; + return Object.assign({ + terminal: terminal.id, + chat: terminal.chat, + cwd: terminal.cwd, + attached: terminal.listeners.size > 0, + }, command ? { command } : {}); + }); } /** Every terminal of a channel, when the channel is killed, archived or deleted; returns how many. */ export function closeChannel(channel: string): number { - const running = list(channel); - for (const terminal of running) close(terminal.terminal); - return running.length; + let count = 0; + for (const terminal of terminals.values()) { + if (terminal.channel !== channel) continue; + close(terminal.id); + count++; + } + return count; } export async function closeAll(): Promise { From 589f192a0b707a02a0605cf7975e88bf05c6b274 Mon Sep 17 00:00:00 2001 From: iamnbutler Date: Thu, 22 Oct 2026 18:19:57 -0400 Subject: [PATCH 2/2] lane: sync fix-term-paste memory --- .../01M5JV5YSFRZMQZ21HV0XVQQXV-without-bracketed-paste-the.md | 4 ++++ .../01M5JV5YV1G8KW4P7JWDSEEWZM-a-shell-counts-as-running-a.md | 4 ++++ 2 files changed, 8 insertions(+) diff --git a/.lane/memory/apps/app/src/panels/terminal.tsx/01M5JV5YSFRZMQZ21HV0XVQQXV-without-bracketed-paste-the.md b/.lane/memory/apps/app/src/panels/terminal.tsx/01M5JV5YSFRZMQZ21HV0XVQQXV-without-bracketed-paste-the.md index 81238a58..45e8958a 100644 --- a/.lane/memory/apps/app/src/panels/terminal.tsx/01M5JV5YSFRZMQZ21HV0XVQQXV-without-bracketed-paste-the.md +++ b/.lane/memory/apps/app/src/panels/terminal.tsx/01M5JV5YSFRZMQZ21HV0XVQQXV-without-bracketed-paste-the.md @@ -3,6 +3,10 @@ id: 01M5JV5YSFRZMQZ21HV0XVQQXV anchor: function Terminal created: 2026-10-22T22:19:50Z norm: '1' +sig: 9ad6b07a24fa0261 +body_hash: 64b84a2362d4e4c2 +raw_hash: d6711e6e6b79d058 +lines: 135-349 --- without bracketed paste the shell runs each pasted line, so a multi-line paste must confirm first; Cmd/Ctrl+K is left to the window's palette, not cleared diff --git a/.lane/memory/apps/host/src/terminals.ts/01M5JV5YV1G8KW4P7JWDSEEWZM-a-shell-counts-as-running-a.md b/.lane/memory/apps/host/src/terminals.ts/01M5JV5YV1G8KW4P7JWDSEEWZM-a-shell-counts-as-running-a.md index 0b621e86..cadb0e91 100644 --- a/.lane/memory/apps/host/src/terminals.ts/01M5JV5YV1G8KW4P7JWDSEEWZM-a-shell-counts-as-running-a.md +++ b/.lane/memory/apps/host/src/terminals.ts/01M5JV5YV1G8KW4P7JWDSEEWZM-a-shell-counts-as-running-a.md @@ -3,6 +3,10 @@ id: 01M5JV5YV1G8KW4P7JWDSEEWZM anchor: function list created: 2026-10-22T22:19:50Z norm: '1' +sig: 7c46e8b2546d6dd7 +body_hash: dd2787067e2d732c +raw_hash: 6cf412ab0b845d01 +lines: 157-174 --- a shell counts as running a command only when its terminal's foreground group isn't the shell's own; prompt helpers that are children of the shell must not count