Show activity indicators for terminals and their workspaces (#1507)

* feat: show activity indicators for terminals and their workspaces

A terminal surfaces whether the agent running inside it is working, idle,
or waiting for input as a tab dot, and that state rolls up into the
owning workspace status — so an agent run inside a terminal reads the
same as a native agent. Finishing a turn raises a "Terminal finished"
notification that routes back to the workspace terminal tab.

Activity is reported by the agents, not parsed from terminal output:
Claude and Codex command hooks and an OpenCode plugin post coarse state
to a local daemon endpoint, and a source-agnostic per-terminal tracker
maps it to working/idle/attention. Hook installation is opt-in and off
by default because it edits your real agent config files; enable it under
a host's Terminal settings and Paseo installs the hooks globally, runs
them via `paseo hooks <agent> <event>`, and removes its own hooks when
you opt back out. Installation and reporting are idempotent and
fail-open, so a missing or failing hook never breaks the user's shell.

* Fix terminal activity routing and CI test

Test drift: anchor the provider-leak test to the repo root instead of process.cwd() so it passes under the server workspace CI runner.

Also restore auth-first JSON parsing outside the terminal-activity route and route subdirectory terminal activity to the deepest active workspace.

* Fix workspace resolver regression

Code drift: terminal activity routing needs parent-workspace prefix matching, but agent directory and historical fetches keep exact registered workspace semantics, including archived exact matches.

* Narrow terminal/draft workspace subscriptions to the fields they use

terminal-panel and the draft agent tab subscribed to the whole workspace
descriptor via useWorkspace but read only workspaceDirectory (plus projectKind
in TerminalPanel, plus id in the draft tab), so they re-rendered on any field
change of that workspace (status, diffStat, scripts). Switch them to the
existing useWorkspaceDirectory / useWorkspaceFields hooks so they re-render only
when the fields they actually read change. No behavior change.

* Make TerminalActivity.state forward-compatible

A strict z.enum rejects the whole payload when a newer daemon reports a
terminal activity state this client predates, breaking the protocol's
old-client-parses-new-daemon contract (the activity field has no .catch at
its embedding in messages.ts, so the failure bubbles up). Degrade unknown
states to "idle" (no indicator, no notification) via .catch, matching the
enum-with-catch pattern used elsewhere in the protocol. Add a forward-compat
regression test.
This commit is contained in:
Mohamed Boudra
2026-06-14 23:29:14 +08:00
committed by GitHub
parent 21deb08673
commit 0d76654ba0
76 changed files with 4873 additions and 127 deletions
+201 -7
View File
@@ -3,7 +3,7 @@ import xterm, { type Terminal as TerminalType } from "@xterm/headless";
import { randomUUID } from "crypto";
import { chmodSync, existsSync, mkdirSync, readFileSync, statSync } from "node:fs";
import { tmpdir, userInfo } from "node:os";
import { basename, dirname, extname, join } from "node:path";
import { basename, delimiter, dirname, extname, join, resolve as resolvePath } from "node:path";
import { createRequire } from "node:module";
import { fileURLToPath } from "node:url";
import { createExternalProcessEnv } from "../server/paseo-env.js";
@@ -11,9 +11,12 @@ import { writePrivateFileAtomicSync } from "../server/private-files.js";
import { findExecutable } from "../executable-resolution/executable-resolution.js";
import type { TerminalCell, TerminalState } from "@getpaseo/protocol/messages";
import { TerminalInputModeTracker } from "@getpaseo/protocol/terminal-input-mode";
import { TerminalActivityTracker } from "./activity/terminal-activity-tracker.js";
import type { TerminalActivity, TerminalActivityState } from "@getpaseo/protocol/terminal-activity";
const { Terminal } = xterm;
const require = createRequire(import.meta.url);
const PASEO_CLI_BIN_ENTRY = "@getpaseo/cli/bin/paseo";
let nodePtySpawnHelperChecked = false;
const TERMINAL_TITLE_DEBOUNCE_MS = 150;
const TERMINAL_EXIT_OUTPUT_LINE_LIMIT = 12;
@@ -65,6 +68,11 @@ export type ServerMessage =
| { type: "snapshotReady"; revision?: number; replayPreamble?: string }
| { type: "titleChange"; title?: string };
export interface TerminalActivityTransition {
activity: TerminalActivity | null;
previous: TerminalActivity | null;
}
export interface TerminalSession {
id: string;
name: string;
@@ -74,11 +82,15 @@ export interface TerminalSession {
onExit(listener: (info: TerminalExitInfo) => void): () => void;
onCommandFinished(listener: (info: TerminalCommandFinishedInfo) => void): () => void;
onTitleChange(listener: (title?: string) => void): () => void;
onActivityChange(listener: (transition: TerminalActivityTransition) => void): () => void;
getSize(): { rows: number; cols: number };
getState(): TerminalState;
getStateSnapshot(options?: TerminalStateSnapshotOptions): TerminalStateSnapshot;
getReplayPreamble(): string;
getTitle(): string | undefined;
getActivity(): TerminalActivity | null;
setActivity(state: TerminalActivityState): void;
clearActivityAttention(): boolean;
setTitle(title: string): void;
getExitInfo(): TerminalExitInfo | null;
kill(): void;
@@ -107,6 +119,7 @@ export interface CreateTerminalOptions {
cwd: string;
shell?: string;
env?: Record<string, string>;
activityEnv?: Record<string, string>;
rows?: number;
cols?: number;
name?: string;
@@ -115,10 +128,26 @@ export interface CreateTerminalOptions {
args?: string[];
}
function toTerminalActivity(snapshot: {
state: TerminalActivityState | null;
changedAt: number;
}): TerminalActivity | null {
if (!snapshot.state) {
return null;
}
return { state: snapshot.state, changedAt: snapshot.changedAt };
}
function resolveInitialTitleMode(presetTitle: string | undefined): "auto" | "manual" {
return presetTitle?.trim() ? "manual" : "auto";
}
interface BuildTerminalEnvironmentInput {
shell: string;
env: Record<string, string>;
zshShellIntegrationDir?: string;
paseoCliBinDir?: string | null;
paseoHookCliPath?: string | null;
}
interface EnsureNodePtySpawnHelperExecutableOptions {
@@ -268,7 +297,77 @@ export function resolveZshShellIntegrationDir(): string {
}
function resolveExternalProcessPath(filePath: string): string {
return filePath.replace(/\.asar(?=\/|$)/, ".asar.unpacked");
return filePath.replace(/\.asar(?=[/\\]|$)/, ".asar.unpacked");
}
export function resolvePaseoCliBinDir(): string | null {
const cliEntrypoint = resolvePaseoCliBinEntrypoint();
if (!cliEntrypoint) {
return null;
}
const externalCliEntrypoint = resolveExternalProcessPath(cliEntrypoint);
return findNpmBinDir(dirname(externalCliEntrypoint)) ?? dirname(externalCliEntrypoint);
}
export function resolvePaseoCliExecutablePath(): string | null {
const cliEntrypoint = resolvePaseoCliBinEntrypoint();
if (!cliEntrypoint) {
return null;
}
const externalCliEntrypoint = resolveExternalProcessPath(cliEntrypoint);
const npmBinDir = findNpmBinDir(dirname(externalCliEntrypoint));
if (npmBinDir) {
const shim = resolvePaseoCliShim(npmBinDir);
if (shim) {
return shim;
}
}
return externalCliEntrypoint;
}
function resolvePaseoCliBinEntrypoint(): string | null {
try {
return require.resolve(PASEO_CLI_BIN_ENTRY);
} catch {
return null;
}
}
function findNpmBinDir(startPath: string): string | null {
let current = startPath;
while (true) {
const candidate = join(current, "node_modules", ".bin");
if (hasPaseoCliShim(candidate)) {
return candidate;
}
const parent = dirname(current);
if (parent === current) {
return null;
}
current = parent;
}
}
function hasPaseoCliShim(binDir: string): boolean {
return resolvePaseoCliShim(binDir) !== null;
}
function resolvePaseoCliShim(binDir: string): string | null {
for (const name of paseoCliShimNames()) {
const candidate = join(binDir, name);
if (existsSync(candidate)) {
return candidate;
}
}
return null;
}
function paseoCliShimNames(): string[] {
return process.platform === "win32" ? ["paseo.cmd", "paseo.exe", "paseo"] : ["paseo"];
}
function resolveZshShellIntegrationRuntimeDir(): string {
@@ -304,19 +403,66 @@ export function buildTerminalEnvironment(
TERM: "xterm-256color",
TERM_PROGRAM: "kitty",
});
const envWithAgentHooks = prependPaseoCliToPath(
baseEnv,
input.paseoCliBinDir === undefined ? resolvePaseoCliBinDir() : input.paseoCliBinDir,
);
const envWithHookCli = injectPaseoHookCli(
envWithAgentHooks,
input.paseoHookCliPath === undefined ? resolvePaseoCliExecutablePath() : input.paseoHookCliPath,
);
if (basename(input.shell) !== "zsh") {
return baseEnv;
return envWithHookCli;
}
const originalZdotdir = baseEnv.ZDOTDIR ?? "";
const originalZdotdir = envWithHookCli.ZDOTDIR ?? "";
return {
...baseEnv,
...envWithHookCli,
PASEO_ZSH_ZDOTDIR: originalZdotdir,
ZDOTDIR: prepareZshShellIntegrationRuntimeDir(input.zshShellIntegrationDir),
};
}
function injectPaseoHookCli(
env: Record<string, string>,
cliPath: string | null,
): Record<string, string> {
if (!cliPath) {
return env;
}
return {
...env,
PASEO_HOOK_CLI: resolvePath(resolveExternalProcessPath(cliPath)),
};
}
function prependPaseoCliToPath(
env: Record<string, string>,
cliBinDir: string | null,
): Record<string, string> {
if (!cliBinDir) {
return env;
}
const pathKey = getPathEnvKey(env);
const currentPath = env[pathKey] ?? "";
return {
...env,
[pathKey]: prependPathEntry(currentPath, cliBinDir),
};
}
function getPathEnvKey(env: Record<string, string>): string {
return Object.keys(env).find((key) => key.toLowerCase() === "path") ?? "PATH";
}
function prependPathEntry(currentPath: string, entry: string): string {
const entries = currentPath.split(delimiter).filter((value) => value && value !== entry);
return [entry, ...entries].join(delimiter);
}
function extractCell(terminal: TerminalType, row: number, col: number): TerminalCell {
const buffer = terminal.buffer.active;
const line = buffer.getLine(row);
@@ -648,6 +794,7 @@ export async function createTerminal(options: CreateTerminalOptions): Promise<Te
cwd,
shell,
env = {},
activityEnv = {},
rows = 24,
cols = 80,
name = "Terminal",
@@ -675,13 +822,15 @@ export async function createTerminal(options: CreateTerminalOptions): Promise<Te
const recentOutputChunks: string[] = [];
let recentOutputLength = 0;
let title: string | undefined;
let titleMode: "auto" | "manual" = presetTitle?.trim() ? "manual" : "auto";
let titleMode = resolveInitialTitleMode(presetTitle);
let pendingTitle: string | undefined;
let titleDebounceTimer: ReturnType<typeof setTimeout> | null = null;
let pendingInput = "";
let inputFlushImmediate: ReturnType<typeof setImmediate> | null = null;
let stateRevision = 0;
const inputModeTracker = new TerminalInputModeTracker();
const activityTracker = new TerminalActivityTracker();
const activityChangeListeners = new Set<(transition: TerminalActivityTransition) => void>();
let titleChangeSubscription: { dispose(): void } | null = null;
// Create xterm.js headless terminal
@@ -703,7 +852,7 @@ export async function createTerminal(options: CreateTerminalOptions): Promise<Te
cols,
rows,
cwd,
env: buildTerminalEnvironment({ shell: spawnCommand, env }),
env: buildTerminalEnvironment({ shell: spawnCommand, env: { ...env, ...activityEnv } }),
});
function emitTitleChange(nextTitle: string | undefined): void {
@@ -834,6 +983,23 @@ export async function createTerminal(options: CreateTerminalOptions): Promise<Te
return true;
});
activityTracker.onChange((snapshot, previousSnapshot) => {
if (disposed || killed) {
return;
}
const transition: TerminalActivityTransition = {
activity: toTerminalActivity(snapshot),
previous: toTerminalActivity(previousSnapshot),
};
for (const listener of Array.from(activityChangeListeners)) {
try {
listener(transition);
} catch {
// no-op
}
}
});
function buildExitInfo(input?: {
exitCode?: number | null;
signal?: number | null;
@@ -873,6 +1039,7 @@ export async function createTerminal(options: CreateTerminalOptions): Promise<Te
return;
}
disposed = true;
activityTracker.clear();
pendingInput = "";
recentOutputChunks.length = 0;
recentOutputLength = 0;
@@ -884,11 +1051,13 @@ export async function createTerminal(options: CreateTerminalOptions): Promise<Te
clearPendingTitleChange();
disposeTitleChangeSubscription();
disposeCommandLifecycleSubscription.dispose();
activityTracker.dispose();
terminal.dispose();
listeners.clear();
exitListeners.clear();
commandFinishedListeners.clear();
titleChangeListeners.clear();
activityChangeListeners.clear();
}
function writeOutputToHeadless(data: string): void {
@@ -1147,10 +1316,31 @@ export async function createTerminal(options: CreateTerminalOptions): Promise<Te
};
}
function onActivityChange(
listener: (transition: TerminalActivityTransition) => void,
): () => void {
activityChangeListeners.add(listener);
return () => {
activityChangeListeners.delete(listener);
};
}
function getTitle(): string | undefined {
return title;
}
function getActivity(): TerminalActivity | null {
return toTerminalActivity(activityTracker.getSnapshot());
}
function setActivity(state: TerminalActivityState): void {
activityTracker.set(state);
}
function clearActivityAttention(): boolean {
return activityTracker.clearAttention();
}
function getExitInfo(): TerminalExitInfo | null {
return exitInfo;
}
@@ -1248,11 +1438,15 @@ export async function createTerminal(options: CreateTerminalOptions): Promise<Te
onExit,
onCommandFinished,
onTitleChange,
onActivityChange,
getSize,
getState,
getStateSnapshot,
getReplayPreamble,
getTitle,
getActivity,
setActivity,
clearActivityAttention,
setTitle,
getExitInfo,
kill,