Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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
---

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
---

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
---
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
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
---
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
34 changes: 29 additions & 5 deletions apps/app/src/app.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -355,6 +357,7 @@ export function App() {
const [draft, setDraft] = useState<ChannelDraft>();
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<AppProject>();
const [askRemove, setAskRemove] = useLocalStorage("ace:remove-project-ask", true);
Expand Down Expand Up @@ -842,6 +845,18 @@ export function App() {
}
}

async function archive(id: string, name: string) {
let shells: TerminalShell[];
try {
shells = await host.request<TerminalShell[]>({ 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<number>({ op: "archive-inactive", project });
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -1222,6 +1235,17 @@ export function App() {
onCancel={() => setRemoving(undefined)}
/>
)}
{archiving && (
<ArchiveChannel
name={archiving.name}
commands={archiving.commands}
onArchive={() => {
setArchiving(undefined);
void change({ op: "archive", channel: archiving.id, archived: true });
}}
onCancel={() => setArchiving(undefined)}
/>
)}
{deleting && (
<DeleteChannel
name={deleting.name}
Expand Down
45 changes: 45 additions & 0 deletions apps/app/src/archive-channel.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
import {
Button,
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@ace/ui";

type Props = {
name: string;
/** Commands in progress in the channel's terminals. */
commands: string[];
onArchive: () => 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 (
<Dialog open onOpenChange={next => next || onCancel()}>
<DialogContent className="gap-3 sm:max-w-80" showCloseButton={false}>
<DialogHeader>
<DialogTitle className="break-all">Archive #{name}?</DialogTitle>
<DialogDescription>
{count} still running <code>{list}</code>. Archiving ends {commands.length === 1
? "it"
: "them"}.
</DialogDescription>
</DialogHeader>
<DialogFooter>
<Button type="button" variant="outline" onClick={onCancel}>
Cancel
</Button>
<Button type="button" variant="destructive" onClick={onArchive}>
Archive channel
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}
2 changes: 1 addition & 1 deletion apps/app/src/palette.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down
63 changes: 55 additions & 8 deletions apps/app/src/panels/terminal.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -130,6 +141,7 @@ export function Terminal({ channel, chat, active, terminal, onTerminal }: Props)
const replaying = useRef(0);
const [node, setNode] = useState<HTMLDivElement | null>(null);
const [state, setState] = useState<State>({ status: "idle" });
const [pasting, setPasting] = useState<string>();
const status = useSyncExternalStore(host.subscribe, () => host.status);
const scheme = useResolvedTheme();
const schemeRef = useRef(scheme);
Expand Down Expand Up @@ -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.
Expand All @@ -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();
Expand Down Expand Up @@ -290,6 +308,35 @@ export function Terminal({ channel, chat, active, terminal, onTerminal }: Props)
{notice}
</div>
)}
{pasting !== undefined && (
<Dialog
open
onOpenChange={next => next || setPasting(undefined)}
>
<DialogContent className="gap-3 sm:max-w-80" showCloseButton={false}>
<DialogHeader>
<DialogTitle>Paste {lines(pasting)} lines?</DialogTitle>
<DialogDescription>
The shell runs each line as a command as soon as it's pasted.
</DialogDescription>
</DialogHeader>
<DialogFooter>
<Button type="button" variant="outline" onClick={() => setPasting(undefined)}>
Cancel
</Button>
<Button
type="button"
onClick={() => {
shell.current?.paste(pasting);
setPasting(undefined);
}}
>
Paste
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
)}
<div className="min-h-0 flex-1 p-3">
<div
ref={setNode}
Expand Down
9 changes: 8 additions & 1 deletion apps/host/src/protocol.ts
Original file line number Diff line number Diff line change
Expand Up @@ -486,6 +486,13 @@ export type TerminalFrame =
export type TerminalOpened = { terminal: string; cwd: string };

/** `attached` while some client shows the shell. */
export type TerminalShell = { terminal: string; chat?: number; cwd: string; attached: boolean };
export type TerminalShell = {
terminal: string;
chat?: number;
cwd: string;
attached: boolean;
/** What the shell is running, when a command is in progress. */
command?: string;
};

export type { ChannelInfo, Event, ModelRef, Presence, Request };
51 changes: 40 additions & 11 deletions apps/host/src/terminals.ts
Original file line number Diff line number Diff line change
Expand Up @@ -137,22 +137,51 @@ export function close(id: string) {
}
}

/** A channel's running shells, including those no tab shows any more. */
export function list(channel: string): TerminalShell[] {
const shells: TerminalShell[] = [];
for (const terminal of terminals.values()) {
if (terminal.channel !== channel) continue;
const attached = terminal.listeners.size > 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<Map<number, { foreground: number; command: string }>> {
const ps = Bun.spawn(["ps", "-axo", "pid=,tpgid=,comm="], { stdout: "pipe", stderr: "ignore" });
const table = new Map<number, { foreground: number; command: string }>();
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<TerminalShell[]> {
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<void> {
Expand Down