+
+ ${this.renderNavigationPanelEdgeControl()}
+
${this.renderContextBar()}
- 0} .loadingMore=${state.isLoadingEarlierMessages} .isReceivingPartialStream=${state.isReceivingPartialStream} .isCompacting=${state.status?.isCompacting === true} .pendingMessageCount=${state.status?.pendingMessageCount ?? 0} .status=${state.status} .activity=${state.activity} .onLoadMore=${() => this.withChatPrependTransition(() => this.sessions.loadEarlierMessages())}>
+ 0} .loadingMore=${state.isLoadingEarlierMessages} .isReceivingPartialStream=${state.isReceivingPartialStream} .isCompacting=${state.status?.isCompacting === true} .pendingMessageCount=${state.status?.pendingMessageCount ?? 0} .status=${state.status} .activity=${state.activity} .onLoadMore=${() => this.withChatPrependTransition(() => this.sessions.loadEarlierMessages())}>
0} .status=${state.status} .onSend=${(text: string, streamingBehavior?: "steer" | "followUp") => { this.sendPrompt(text, streamingBehavior); }} .onStop=${() => this.sessions.stopActiveWork()} .onSelectModel=${() => { void this.openModelDialog(); }} .onSelectThinking=${() => { void this.openThinkingDialog(); }}>
${state.commandDialog !== undefined ? html` this.sessions.respondToCommand(state.commandDialog?.requestId ?? "", value)} .onCancel=${() => { this.sessions.cancelCommand(); }}> ` : null}
@@ -1345,11 +1117,11 @@ export class PiWebApp extends LitElement {
${state.authDialog !== undefined ? html` { void this.auth.chooseLoginMethod(authType); }} .onSelectProvider=${(providerId: string, authType: "oauth" | "api_key") => { void this.auth.selectLoginProvider(providerId, authType); }} .onApiKeyInput=${(value: string) => { this.auth.updateApiKey(value); }} .onSaveApiKey=${() => { void this.auth.saveApiKey(); }} .onLogoutProvider=${(providerId: string) => { void this.auth.logoutProvider(providerId); }} .onOAuthInput=${(value: string) => { this.auth.updateOAuthInput(value); }} .onOAuthRespond=${(value?: string) => { void this.auth.respondOAuth(value); }} .onOAuthCancel=${() => { void this.auth.cancelOAuth(); }} .onCancel=${() => { this.auth.closeDialog(); }}> ` : null}
` : html`
+ ${this.renderWorkspacePanelEdgeControl()}
${this.renderWorkspacePanel()}
${state.actionPaletteOpen ? html` { this.setState({ actionPaletteOpen: false }); this.runAction(action); }} .onCancel=${() => { this.setState({ actionPaletteOpen: false }); }}> ` : null}
${state.projectDialogOpen ? html` this.projects.addProject(path, create)} .onCancel=${() => { this.setState({ projectDialogOpen: false }); }}> ` : null}
${state.themeDialog !== undefined ? html` { this.pickTheme(value); }} .onCancel=${() => { this.setState({ themeDialog: undefined }); }}> ` : null}
- ${this.renderRefreshMenu()}
`;
}
@@ -1364,39 +1136,6 @@ function createPluginRegistry(): PluginRegistry {
return registry;
}
-function machineContextLabel(machine: Machine | undefined): string {
- return machine === undefined ? "No machine" : `${machine.name}${machine.kind === "remote" ? " · remote" : ""}`;
-}
-
-function machineContextTitle(machine: Machine | undefined): string {
- return machine === undefined ? "No machine selected" : machine.baseUrl ?? machine.name;
-}
-
-function projectContextLabel(project: Project | undefined): string {
- return project?.name ?? "No project";
-}
-
-function projectContextTitle(project: Project | undefined): string {
- return project === undefined ? "No project selected" : `${project.name} — ${project.path}`;
-}
-
-function workspaceContextLabel(workspace: Workspace | undefined): string {
- return workspace === undefined ? "No workspace" : `${workspace.label}${workspace.isMain ? " · main" : ""} · ${workspace.path}`;
-}
-
-function workspaceContextTitle(workspace: Workspace | undefined): string {
- return workspace === undefined ? "No workspace selected" : `${workspace.label}${workspace.isMain ? " · main" : ""} — ${workspace.path}`;
-}
-
-function sessionContextLabel(session: SessionInfo | undefined): string {
- const name = session?.name?.trim();
- const firstMessage = session?.firstMessage.trim();
- return name !== undefined && name !== "" ? name : firstMessage !== undefined && firstMessage !== "" ? firstMessage : session?.id.slice(0, 8) ?? "No session";
-}
-
-function sessionContextTitle(session: SessionInfo | undefined): string {
- return session === undefined ? "No session selected" : session.path;
-}
function patchChangesState(state: AppState, patch: Partial
-
+ ${this.renderMobileMainTabs()}
${state.error ? html`
-
-
- ${this.visibleWorkspacePanels().map((panel) => html`
-
- `)}
-
- ${state.error}
` : null}
- ${this.isMobileNavigationLayout ? this.renderNavigationPanel(true) : null}
+ ${this.appShell.isMobileNavigationLayout ? this.renderNavigationPanel(true) : null}
${state.selectedSession ? html`
- ${this.sessionEmptyMessage()}
`}
${this.renderHeading()}
- ${this.collapsed ? null : this.projects.map((project) => html` - { activateSelectableRow(event, () => this.onSelect?.(project)); }}
- @keydown=${(event: KeyboardEvent) => { activateSelectableRowFromKeyboard(event, () => this.onSelect?.(project)); }}
- >
-
- ${project.name}${this.renderActivity(project)}${project.path}
-
-
-
- ${this.openMenuProjectId === project.id ? html`
-
${this.renderHeading(activeRows.length + archivedRows.length)}
- ${this.collapsed ? null : activeRows.map((row) => this.renderSession(row, descendantCounts.get(row.session.id) ?? 0))}
- ${this.collapsed ? null : archivedRows.length > 0 ? html`
-
- ${this.archivedExpanded ? archivedRows.map((row) => this.renderSession(row, descendantCounts.get(row.session.id) ?? 0)) : null}
- ` : null}
+ ${this.collapsed ? null : html`
+
`;
}
diff --git a/src/client/src/components/TerminalPanel.ts b/src/client/src/components/TerminalPanel.ts
index d5cb430..2122810 100644
--- a/src/client/src/components/TerminalPanel.ts
+++ b/src/client/src/components/TerminalPanel.ts
@@ -5,6 +5,9 @@ import { FitAddon, type ITerminalDimensions } from "@xterm/addon-fit";
import "@xterm/xterm/css/xterm.css";
import { terminalSocket, terminalsApi, type TerminalCommandRun, type TerminalInfo, type Workspace } from "../api";
import { selectFallbackTerminal, selectPreferredTerminal } from "../controllers/terminalSelection";
+import { createTerminalSoftKeysDefaultEnvironmentMedia, hasTerminalSoftKeysPreference, initialTerminalSoftKeysEnabled, isTerminalSoftKeysDefaultEnvironment, writeTerminalSoftKeysPreference } from "../terminalSoftKeysPreference";
+import "./TerminalSoftKeys";
+import type { TerminalSoftKeyInputOptions } from "./TerminalSoftKeys";
const TERMINAL_OPTIONS_BASE: ITerminalOptions = {
cursorBlink: true,
@@ -32,6 +35,8 @@ export class TerminalPanel extends LitElement {
@state() private visible = false;
@state() private cancellingRunIds: string[] = [];
@state() private continuingTerminalIds: string[] = [];
+ @state() private defaultSoftKeysEnvironment = false;
+ @state() private softKeysEnabled = initialTerminalSoftKeysEnabled();
private terminal: Terminal | undefined;
private fitAddon: FitAddon | undefined;
@@ -44,9 +49,16 @@ export class TerminalPanel extends LitElement {
private loadedCwd: string | undefined;
private autoStartConsumedCwd: string | undefined;
private commandRunPollTimer: number | undefined;
+ private readonly softKeysDefaultEnvironmentMedia = createTerminalSoftKeysDefaultEnvironmentMedia();
+ private softKeysPreferenceStored = hasTerminalSoftKeysPreference();
+ private readonly onSoftKeysDefaultEnvironmentChange = () => {
+ this.syncDefaultSoftKeysEnvironment();
+ };
override connectedCallback(): void {
super.connectedCallback();
+ this.syncDefaultSoftKeysEnvironment();
+ this.softKeysDefaultEnvironmentMedia?.addEventListener("change", this.onSoftKeysDefaultEnvironmentChange);
this.themeObserver = new MutationObserver(() => { this.applyTerminalTheme(); });
this.themeObserver.observe(document.documentElement, { attributes: true, attributeFilter: ["class", "style", "data-theme"] });
}
@@ -63,11 +75,24 @@ export class TerminalPanel extends LitElement {
this.intersectionObserver = undefined;
this.themeObserver?.disconnect();
this.themeObserver = undefined;
+ this.softKeysDefaultEnvironmentMedia?.removeEventListener("change", this.onSoftKeysDefaultEnvironmentChange);
this.updateCommandRunPolling(false);
this.disposeTerminalView();
super.disconnectedCallback();
}
+ private syncDefaultSoftKeysEnvironment(): void {
+ const nextDefaultEnvironment = isTerminalSoftKeysDefaultEnvironment(this.softKeysDefaultEnvironmentMedia);
+ const previousSoftKeysEnabled = this.softKeysEnabled;
+ this.defaultSoftKeysEnvironment = nextDefaultEnvironment;
+ if (!this.softKeysPreferenceStored) this.softKeysEnabled = nextDefaultEnvironment;
+ if (this.softKeysEnabled !== previousSoftKeysEnabled) this.scheduleFitAndNotify();
+ }
+
+ private scheduleFitAndNotify(): void {
+ void this.updateComplete.then(() => { this.fitAndNotify(); });
+ }
+
override willUpdate(changed: PropertyValues): void {
const workspaceScope = this.workspace === undefined ? undefined : JSON.stringify([this.machineId, this.workspace.path]);
if (workspaceScope !== this.observedWorkspaceScope) {
@@ -287,8 +312,7 @@ export class TerminalPanel extends LitElement {
this.resizeObserver.observe(terminalHost);
terminal.onData((data) => {
if (this.suppressTerminalInput) return;
- const filtered = filterTerminalInput(data);
- if (filtered !== "") this.send({ type: "input", data: filtered });
+ this.sendTerminalInput(data);
});
const initialSize = this.fitTerminal();
this.connectSocket(workspace.projectId, workspace.id, this.selectedId, terminal, initialSize);
@@ -376,6 +400,23 @@ export class TerminalPanel extends LitElement {
if (this.terminal !== undefined) this.terminal.options.theme = terminalTheme(this);
}
+ private sendTerminalInput(data: string): void {
+ const filtered = filterTerminalInput(data);
+ if (filtered !== "") this.send({ type: "input", data: filtered });
+ }
+
+ private sendSoftKeyInput(data: string, options: TerminalSoftKeyInputOptions): void {
+ this.sendTerminalInput(data);
+ if (options.refocus) this.focusTerminal();
+ }
+
+ private focusTerminal(): void {
+ const terminal = this.terminal;
+ if (terminal === undefined) return;
+ terminal.focus();
+ requestAnimationFrame(() => { terminal.focus(); });
+ }
+
private send(message: { type: "input"; data: string } | { type: "resize"; cols: number; rows: number }): void {
if (this.socket?.readyState === WebSocket.OPEN) this.socket.send(JSON.stringify(message));
}
@@ -423,10 +464,61 @@ export class TerminalPanel extends LitElement {
return null;
}
+ private selectedTerminalAcceptsInput(): boolean {
+ const terminal = this.selectedTerminalInfo();
+ return terminal !== undefined && !terminal.exited;
+ }
+
+ private shouldShowSoftKeys(): boolean {
+ return this.selectedTerminalAcceptsInput() && this.softKeysEnabled;
+ }
+
+ private shouldShowSoftKeysToggle(): boolean {
+ return this.selectedTerminalAcceptsInput();
+ }
+
+ private toggleSoftKeys(): void {
+ this.softKeysEnabled = !this.softKeysEnabled;
+ this.softKeysPreferenceStored = true;
+ writeTerminalSoftKeysPreference(this.softKeysEnabled);
+ this.scheduleFitAndNotify();
+ }
+
+ private renderSoftKeysToggle() {
+ if (!this.shouldShowSoftKeysToggle()) return null;
+ return html`
+
+ `;
+ }
+
+ private renderSoftKeys() {
+ return html`
+ { this.sendSoftKeyInput(data, options); }}
+ >
+ `;
+ }
+
override render() {
return html`
@@ -450,6 +543,8 @@ export class TerminalPanel extends LitElement {
button { display: inline-flex; align-items: center; gap: 6px; min-width: 0; max-width: 180px; border: 1px solid var(--pi-border); border-radius: 7px; background: var(--pi-surface); color: var(--pi-text); padding: 5px 7px; cursor: pointer; }
button.selected { border-color: var(--pi-accent); background: var(--pi-selection-bg); }
button.new { flex: 0 0 auto; color: var(--pi-muted); }
+ .soft-keys-toggle { flex: 0 0 auto; }
+ .soft-keys-toggle .keyboard-icon { flex: 0 0 auto; width: 16px; height: 16px; fill: none; stroke: currentColor; stroke-width: 1.8; stroke-linecap: round; stroke-linejoin: round; pointer-events: none; }
button span { min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
button small { color: var(--pi-muted); font-size: 14px; line-height: 1; }
button small:hover { color: var(--pi-danger); }
diff --git a/src/client/src/components/TerminalSoftKeys.ts b/src/client/src/components/TerminalSoftKeys.ts
new file mode 100644
index 0000000..1c9b0ad
--- /dev/null
+++ b/src/client/src/components/TerminalSoftKeys.ts
@@ -0,0 +1,101 @@
+import { css, html, LitElement } from "lit";
+import { customElement, property } from "lit/decorators.js";
+import { TERMINAL_SOFT_KEYS, terminalSoftKeySequence, type TerminalModesSnapshot, type TerminalSoftKeyDefinition } from "../terminalKeys";
+
+const SOFT_KEY_TAP_MOVE_THRESHOLD_PX = 8;
+const SYNTHETIC_CLICK_SUPPRESSION_MS = 500;
+
+export interface TerminalSoftKeyInputOptions {
+ refocus: boolean;
+}
+
+@customElement("terminal-soft-keys")
+export class TerminalSoftKeys extends LitElement {
+ @property({ attribute: false }) modes: TerminalModesSnapshot | undefined;
+ @property({ type: Boolean }) refocusOnClick = true;
+ @property({ attribute: false }) onInput: (data: string, options: TerminalSoftKeyInputOptions) => void = () => undefined;
+
+ private pointerStart: SoftKeyPointerStart | undefined;
+ private lastPointerFinishedAt = 0;
+
+ private sendSoftKey(key: TerminalSoftKeyDefinition, options: TerminalSoftKeyInputOptions): void {
+ this.onInput(terminalSoftKeySequence(key.id, this.modes), options);
+ }
+
+ private onSoftKeyPointerDown(event: PointerEvent, key: TerminalSoftKeyDefinition): void {
+ if (event.pointerType === "mouse" && event.button !== 0) return;
+ event.preventDefault();
+ this.pointerStart = { pointerId: event.pointerId, key, clientX: event.clientX, clientY: event.clientY };
+ }
+
+ private onSoftKeyPointerMove(event: PointerEvent): void {
+ const start = this.pointerStart;
+ if (start?.pointerId !== event.pointerId) return;
+ if (pointerMovedBeyondTap(start, event)) this.finishSoftKeyPointer();
+ }
+
+ private onSoftKeyPointerUp(event: PointerEvent, key: TerminalSoftKeyDefinition): void {
+ const start = this.pointerStart;
+ if (start?.pointerId !== event.pointerId) return;
+ event.preventDefault();
+ this.finishSoftKeyPointer();
+ if (start.key.id !== key.id || pointerMovedBeyondTap(start, event)) return;
+ this.sendSoftKey(key, { refocus: event.pointerType === "mouse" });
+ }
+
+ private onSoftKeyPointerCancel(event: PointerEvent): void {
+ if (this.pointerStart?.pointerId === event.pointerId) this.finishSoftKeyPointer();
+ }
+
+ private finishSoftKeyPointer(): void {
+ this.pointerStart = undefined;
+ this.lastPointerFinishedAt = Date.now();
+ }
+
+ private onSoftKeyClick(event: MouseEvent, key: TerminalSoftKeyDefinition): void {
+ if (Date.now() - this.lastPointerFinishedAt < SYNTHETIC_CLICK_SUPPRESSION_MS) {
+ event.preventDefault();
+ return;
+ }
+ this.sendSoftKey(key, { refocus: this.refocusOnClick });
+ }
+
+ override render() {
+ return html`
+
`;
}
diff --git a/src/client/src/components/actionMenu.test.ts b/src/client/src/components/actionMenu.test.ts
new file mode 100644
index 0000000..ced599e
--- /dev/null
+++ b/src/client/src/components/actionMenu.test.ts
@@ -0,0 +1,27 @@
+import { afterEach, describe, expect, it, vi } from "vitest";
+import { actionMenuPanelStyle } from "./actionMenu";
+
+describe("actionMenuPanelStyle", () => {
+ afterEach(() => {
+ vi.unstubAllGlobals();
+ });
+
+ it("can constrain menus to the viewport for compact shadow-root controls", () => {
+ vi.stubGlobal("window", { innerWidth: 400, innerHeight: 800 });
+ vi.stubGlobal("HTMLElement", FakeHTMLElement);
+
+ const target = new FakeHTMLElement({ top: 10, right: 390, bottom: 46, left: 354 });
+
+ expect(actionMenuPanelStyle(target, { constrainTo: "viewport" })).toBe("top: 46px; max-height: 754px; right: 10px; max-width: 390px;");
+ });
+});
+
+class FakeHTMLElement extends EventTarget {
+ constructor(private readonly rect: { top: number; right: number; bottom: number; left: number }) {
+ super();
+ }
+
+ getBoundingClientRect(): { top: number; right: number; bottom: number; left: number } {
+ return this.rect;
+ }
+}
diff --git a/src/client/src/components/actionMenu.ts b/src/client/src/components/actionMenu.ts
index dac8a8c..5d46c2d 100644
--- a/src/client/src/components/actionMenu.ts
+++ b/src/client/src/components/actionMenu.ts
@@ -8,10 +8,14 @@ interface ActionMenuRect {
left: number;
}
-export function actionMenuPanelStyle(target: EventTarget | null): string {
+interface ActionMenuPanelStyleOptions {
+ constrainTo?: "host" | "viewport";
+}
+
+export function actionMenuPanelStyle(target: EventTarget | null, options: ActionMenuPanelStyleOptions = {}): string {
if (typeof HTMLElement === "undefined" || typeof window === "undefined" || !(target instanceof HTMLElement)) return "";
const trigger = target.getBoundingClientRect();
- const bounds = actionMenuBounds(target);
+ const bounds = options.constrainTo === "viewport" ? viewportBounds() : actionMenuBounds(target);
const viewportWidth = window.innerWidth;
const viewportHeight = window.innerHeight;
const leftBound = Math.max(0, bounds.left);
@@ -35,6 +39,10 @@ export function actionMenuPanelStyle(target: EventTarget | null): string {
function actionMenuBounds(target: HTMLElement): ActionMenuRect {
const root = target.getRootNode();
if (typeof ShadowRoot !== "undefined" && root instanceof ShadowRoot && root.host instanceof HTMLElement) return root.host.getBoundingClientRect();
+ return viewportBounds();
+}
+
+function viewportBounds(): ActionMenuRect {
return { top: 0, right: window.innerWidth, bottom: window.innerHeight, left: 0 };
}
diff --git a/src/client/src/components/appShell/AppContextBar.ts b/src/client/src/components/appShell/AppContextBar.ts
new file mode 100644
index 0000000..78774e1
--- /dev/null
+++ b/src/client/src/components/appShell/AppContextBar.ts
@@ -0,0 +1,171 @@
+import { LitElement, css, html } from "lit";
+import { customElement, property, query, state } from "lit/decorators.js";
+import type { Machine, Project, SessionInfo, Workspace } from "../../api";
+import type { NavigationSection } from "../../appShell/navigationState";
+
+@customElement("app-context-bar")
+export class AppContextBar extends LitElement {
+ @property({ attribute: false }) machine?: Machine;
+ @property({ attribute: false }) project?: Project;
+ @property({ attribute: false }) workspace?: Workspace;
+ @property({ attribute: false }) session?: SessionInfo;
+ @property({ attribute: false }) refreshControl: unknown;
+ @property({ attribute: false }) onOpenSection?: (section: NavigationSection) => void;
+ @query(".context-items") private contextItems?: HTMLElement | null;
+ @state() private canScrollLeft = false;
+ @state() private canScrollRight = false;
+ private observedContextItems: HTMLElement | undefined;
+ private contextItemsResizeObserver: ResizeObserver | undefined;
+
+ override disconnectedCallback(): void {
+ this.contextItemsResizeObserver?.disconnect();
+ this.contextItemsResizeObserver = undefined;
+ this.observedContextItems = undefined;
+ super.disconnectedCallback();
+ }
+
+ override firstUpdated(): void {
+ this.observeContextItems();
+ this.updateScrollState();
+ }
+
+ override updated(): void {
+ this.observeContextItems();
+ this.updateScrollState();
+ }
+
+ override render() {
+ const machineLabel = machineContextLabel(this.machine);
+ const projectLabel = projectContextLabel(this.project);
+ const workspaceLabel = workspaceContextLabel(this.workspace);
+ const sessionLabel = sessionContextLabel(this.session);
+ return html`
+
+ `;
+ }
+
+ private contextBarClass(): string {
+ const classes = ["context-bar"];
+ if (this.refreshControl !== undefined) classes.push("has-context-actions");
+ if (this.canScrollLeft) classes.push("can-scroll-left");
+ if (this.canScrollRight) classes.push("can-scroll-right");
+ return classes.join(" ");
+ }
+
+ private observeContextItems(): void {
+ const contextItems = this.contextItemsElement();
+ if (this.observedContextItems === contextItems) return;
+ this.contextItemsResizeObserver?.disconnect();
+ this.observedContextItems = contextItems;
+ this.contextItemsResizeObserver = undefined;
+ if (contextItems === undefined || typeof ResizeObserver === "undefined") return;
+ this.contextItemsResizeObserver = new ResizeObserver(() => {
+ this.updateScrollState();
+ });
+ this.contextItemsResizeObserver.observe(contextItems);
+ }
+
+ private updateScrollState(): void {
+ const contextItems = this.contextItemsElement();
+ const maxScrollLeft = contextItems === undefined ? 0 : Math.max(0, contextItems.scrollWidth - contextItems.clientWidth);
+ const canScrollLeft = contextItems !== undefined && contextItems.scrollLeft > 1;
+ const canScrollRight = contextItems !== undefined && maxScrollLeft - contextItems.scrollLeft > 1;
+ if (this.canScrollLeft !== canScrollLeft) this.canScrollLeft = canScrollLeft;
+ if (this.canScrollRight !== canScrollRight) this.canScrollRight = canScrollRight;
+ }
+
+ private contextItemsElement(): HTMLElement | undefined {
+ const contextItems = this.contextItems;
+ return contextItems instanceof HTMLElement ? contextItems : undefined;
+ }
+
+ private readonly onContextScroll = () => {
+ this.updateScrollState();
+ };
+
+ static override styles = css`
+ :host { flex: 0 0 auto; min-width: 0; }
+ .context-bar { position: relative; flex: 0 0 auto; min-width: 0; display: flex; align-items: center; gap: 0; padding: 6px 0; border-bottom: 1px solid var(--pi-border-muted); background: var(--pi-bg); }
+ .context-bar::before, .context-bar::after { content: ""; position: absolute; top: 0; bottom: 0; z-index: 2; width: 20px; opacity: 0; pointer-events: none; transition: opacity .15s ease; }
+ .context-bar::before { left: 0; background: linear-gradient(90deg, color-mix(in srgb, var(--pi-shadow-strong) 55%, transparent) 0%, transparent 100%); }
+ .context-bar::after { right: 0; background: linear-gradient(270deg, color-mix(in srgb, var(--pi-shadow-strong) 55%, transparent) 0%, transparent 100%); }
+ .context-bar.can-scroll-left::before, .context-bar.can-scroll-right::after { opacity: 1; }
+ .context-bar-label { display: none; }
+ .context-items { flex: 1 1 auto; min-width: 0; display: flex; align-items: stretch; gap: 5px; margin: 0; padding: 0 8px; list-style: none; overflow-x: auto; overflow-y: hidden; overscroll-behavior-x: contain; scroll-padding-inline: 8px; scrollbar-width: thin; }
+ .context-bar.has-context-actions .context-items { padding-right: 52px; scroll-padding-inline: 8px 52px; }
+ .context-item { flex: 0 0 auto; min-width: 0; display: flex; }
+ .context-actions { position: absolute; top: 6px; right: 0; bottom: 6px; z-index: 3; display: flex; align-items: center; padding: 0 8px 0 0; pointer-events: none; }
+ .context-actions::after { content: ""; position: absolute; top: 0; right: 0; bottom: 0; z-index: 0; width: 26px; background: var(--pi-bg); pointer-events: none; }
+ app-refresh-control { pointer-events: auto; }
+ .context-chip { flex: 0 0 auto; min-width: 0; display: inline-flex; align-items: baseline; gap: 5px; border: 1px solid var(--pi-border-muted); border-radius: 999px; background: var(--pi-surface); color: var(--pi-text); padding: 4px 8px; font: inherit; text-align: left; }
+ .context-chip:hover { background: var(--pi-surface-hover); }
+ .context-chip:focus-visible { outline: 2px solid var(--pi-accent); outline-offset: 2px; }
+ .context-chip.empty { border-style: dashed; color: var(--pi-muted); }
+ .context-kind { display: none; }
+ .context-value { min-width: 0; overflow: visible; text-overflow: clip; white-space: nowrap; }
+ button { cursor: pointer; }
+ `;
+}
+
+function machineContextLabel(machine: Machine | undefined): string {
+ return machine === undefined ? "No machine" : `${machine.name}${machine.kind === "remote" ? " · remote" : ""}`;
+}
+
+function machineContextTitle(machine: Machine | undefined): string {
+ return machine === undefined ? "No machine selected" : machine.baseUrl ?? machine.name;
+}
+
+function projectContextLabel(project: Project | undefined): string {
+ return project?.name ?? "No project";
+}
+
+function projectContextTitle(project: Project | undefined): string {
+ return project === undefined ? "No project selected" : `${project.name} — ${project.path}`;
+}
+
+function workspaceContextLabel(workspace: Workspace | undefined): string {
+ return workspace === undefined ? "No workspace" : `${workspace.label}${workspace.isMain ? " · main" : ""} · ${workspace.path}`;
+}
+
+function workspaceContextTitle(workspace: Workspace | undefined): string {
+ return workspace === undefined ? "No workspace selected" : `${workspace.label}${workspace.isMain ? " · main" : ""} — ${workspace.path}`;
+}
+
+function sessionContextLabel(session: SessionInfo | undefined): string {
+ const name = session?.name?.trim();
+ const firstMessage = session?.firstMessage.trim();
+ return name !== undefined && name !== "" ? name : firstMessage !== undefined && firstMessage !== "" ? firstMessage : session?.id.slice(0, 8) ?? "No session";
+}
+
+function sessionContextTitle(session: SessionInfo | undefined): string {
+ return session === undefined ? "No session selected" : session.path;
+}
diff --git a/src/client/src/components/appShell/AppMobileMainTabs.ts b/src/client/src/components/appShell/AppMobileMainTabs.ts
new file mode 100644
index 0000000..4eaf5fa
--- /dev/null
+++ b/src/client/src/components/appShell/AppMobileMainTabs.ts
@@ -0,0 +1,110 @@
+import { LitElement, css, html } from "lit";
+import { customElement, property, query, state } from "lit/decorators.js";
+import type { AppState } from "../../appState";
+
+export interface AppMobileMainTab {
+ id: AppState["mainView"];
+ label: unknown;
+ className?: string | undefined;
+}
+
+@customElement("app-mobile-main-tabs")
+export class AppMobileMainTabs extends LitElement {
+ @property({ attribute: false }) tabs: AppMobileMainTab[] = [];
+ @property({ attribute: false }) selectedView: AppState["mainView"] = "chat";
+ @property({ attribute: false }) onSelect?: (view: AppState["mainView"]) => void;
+ @query(".mobile-tabs") private mobileTabs?: HTMLElement | null;
+ @state() private canScrollLeft = false;
+ @state() private canScrollRight = false;
+ private observedMobileTabs: HTMLElement | undefined;
+ private mobileTabsResizeObserver: ResizeObserver | undefined;
+
+ override disconnectedCallback(): void {
+ this.mobileTabsResizeObserver?.disconnect();
+ this.mobileTabsResizeObserver = undefined;
+ this.observedMobileTabs = undefined;
+ super.disconnectedCallback();
+ }
+
+ override firstUpdated(): void {
+ this.observeMobileTabs();
+ this.updateScrollState();
+ }
+
+ override updated(): void {
+ this.observeMobileTabs();
+ this.updateScrollState();
+ }
+
+ override render() {
+ return html`
+ = {};
+ @property({ attribute: false }) projects: Project[] = [];
+ @property({ attribute: false }) selectedProject?: Project;
+ @property({ attribute: false }) workspaces: Workspace[] = [];
+ @property({ attribute: false }) selectedWorkspace?: Workspace;
+ @property({ attribute: false }) sessions: SessionInfo[] = [];
+ @property({ attribute: false }) selectedSession?: SessionInfo;
+ @property({ attribute: false }) workspaceActivities: Record = {};
+ @property({ attribute: false }) sessionActivities: Record = {};
+ @property({ attribute: false }) sessionStatuses: Record = {};
+ @property({ attribute: false }) workspacesByProjectId: Record = {};
+ @property({ attribute: false }) deletingWorkspaceIds: string[] = [];
+ @property({ attribute: false }) workspaceLabelItems: (workspace: Workspace) => WorkspaceLabelItem[] = () => [];
+ @property({ attribute: false }) refreshControl: unknown;
+ @property({ type: Boolean, reflect: true }) collapsible = false;
+ @property({ type: Boolean }) machinesCollapsed = false;
+ @property({ type: Boolean }) projectsCollapsed = false;
+ @property({ type: Boolean }) workspacesCollapsed = false;
+ @property({ type: Boolean }) sessionsCollapsed = false;
+ @property({ type: Boolean }) canStartSession = false;
+ @property({ attribute: false }) onShowActions?: () => void;
+ @property({ attribute: false }) onToggleMachines?: () => void;
+ @property({ attribute: false }) onToggleProjects?: () => void;
+ @property({ attribute: false }) onToggleWorkspaces?: () => void;
+ @property({ attribute: false }) onToggleSessions?: () => void;
+ @property({ attribute: false }) onSelectProject?: (project: Project) => void | Promise;
+ @property({ attribute: false }) onCloseProject?: (project: Project) => void | Promise;
+ @property({ attribute: false }) onSelectWorkspace?: (workspace: Workspace) => void | Promise;
+ @property({ attribute: false }) onDeleteWorkspace?: (workspace: Workspace) => void | Promise;
+ @property({ attribute: false }) onStartSession?: () => void | Promise;
+ @property({ attribute: false }) onSelectSession?: (session: SessionInfo) => void | Promise;
+ @property({ attribute: false }) onArchiveSession?: (session: SessionInfo) => void | Promise;
+ @property({ attribute: false }) onArchiveSessionWithDescendants?: (session: SessionInfo) => void | Promise;
+ @property({ attribute: false }) onRestoreSession?: (session: SessionInfo) => void | Promise;
+ @property({ attribute: false }) onDeleteCachedNewSession?: (session: SessionInfo) => void | Promise;
+ @property({ attribute: false }) onDetachParentSession?: (session: SessionInfo) => void | Promise;
+ @property({ attribute: false }) onArchivedCollapsed?: () => void | Promise;
+ @property({ attribute: false }) onSelectMachine?: (machine: Machine) => void | Promise;
+
+ override render() {
+ return html`
+
+ PI WEB
+
+ { this.onToggleMachines?.(); }}
+ .onSelect=${(machine: Machine) => this.onSelectMachine?.(machine)}
+ >
+ { this.onToggleProjects?.(); }}
+ .onSelect=${(project: Project) => this.onSelectProject?.(project)}
+ .onClose=${(project: Project) => this.onCloseProject?.(project)}
+ >
+ { this.onToggleWorkspaces?.(); }}
+ .onSelect=${(workspace: Workspace) => this.onSelectWorkspace?.(workspace)}
+ .onDelete=${(workspace: Workspace) => this.onDeleteWorkspace?.(workspace)}
+ >
+ { this.onToggleSessions?.(); }}
+ .onArchivedCollapsed=${() => this.onArchivedCollapsed?.()}
+ .onStart=${() => this.onStartSession?.()}
+ .onSelect=${(session: SessionInfo) => this.onSelectSession?.(session)}
+ .onArchive=${(session: SessionInfo) => this.onArchiveSession?.(session)}
+ .onArchiveWithDescendants=${(session: SessionInfo) => this.onArchiveSessionWithDescendants?.(session)}
+ .onRestore=${(session: SessionInfo) => this.onRestoreSession?.(session)}
+ .onDelete=${(session: SessionInfo) => this.onDeleteCachedNewSession?.(session)}
+ .onDetachParent=${(session: SessionInfo) => this.onDetachParentSession?.(session)}
+ >
+ `;
+ }
+
+ static override styles = css`
+ :host { display: flex; flex-direction: column; min-height: 0; overflow: hidden; }
+ :host([collapsible]) { flex: 1 1 auto; }
+ header { flex: 0 0 auto; display: flex; align-items: center; justify-content: space-between; gap: 8px; padding: 12px; border-bottom: 1px solid var(--pi-border); }
+ .header-actions { display: flex; align-items: center; gap: 8px; }
+ machine-list, project-list, workspace-list { flex: 0 0 auto; max-height: 26%; min-height: 0; overflow: hidden; border-bottom: 1px solid var(--pi-border-muted); }
+ session-list { flex: 1 1 auto; min-height: 0; overflow: hidden; }
+ :host([collapsible]) machine-list,
+ :host([collapsible]) project-list,
+ :host([collapsible]) workspace-list,
+ :host([collapsible]) session-list { flex: 1 1 auto; max-height: none; min-height: 0; overflow: hidden; }
+ :host([collapsible]) machine-list[collapsed],
+ :host([collapsible]) project-list[collapsed],
+ :host([collapsible]) workspace-list[collapsed],
+ :host([collapsible]) session-list[collapsed] { flex: 0 0 auto; min-height: auto; overflow: hidden; }
+ button { border: 1px solid var(--pi-border); border-radius: 8px; background: var(--pi-surface); color: var(--pi-text); padding: 7px 9px; cursor: pointer; }
+ `;
+}
diff --git a/src/client/src/components/appShell/AppPanelEdgeControl.ts b/src/client/src/components/appShell/AppPanelEdgeControl.ts
new file mode 100644
index 0000000..2f9594f
--- /dev/null
+++ b/src/client/src/components/appShell/AppPanelEdgeControl.ts
@@ -0,0 +1,58 @@
+import { LitElement, css, html } from "lit";
+import { customElement, property } from "lit/decorators.js";
+
+export type PanelEdgeSide = "navigation" | "workspace";
+
+@customElement("app-panel-edge-control")
+export class AppPanelEdgeControl extends LitElement {
+ @property({ reflect: true }) side: PanelEdgeSide = "navigation";
+ @property({ type: Boolean, reflect: true }) collapsed = false;
+ @property() controls = "";
+ @property() expandLabel = "Expand panel";
+ @property() collapseLabel = "Collapse panel";
+ @property({ attribute: false }) onToggle?: () => void;
+
+ override render() {
+ const label = this.collapsed ? this.expandLabel : this.collapseLabel;
+ return html`
+
+ `;
+ }
+
+ private renderIcon() {
+ const direction = this.iconDirection();
+ const path = direction === "left" ? "M15 18l-6-6 6-6" : "M9 18l6-6-6-6";
+ return html``;
+ }
+
+ private iconDirection(): "left" | "right" {
+ if (this.side === "navigation") return this.collapsed ? "right" : "left";
+ return this.collapsed ? "left" : "right";
+ }
+
+ static override styles = css`
+ :host { min-width: 0; min-height: 0; display: flex; align-items: center; justify-content: center; overflow: visible; background: var(--pi-border-muted); z-index: 2; }
+ :host([side="navigation"]) { grid-column: 2; }
+ :host([side="workspace"]) { grid-column: 4; }
+ .edge-button { position: relative; z-index: 1; box-sizing: border-box; display: grid; place-items: center; width: 18px; height: 48px; padding: 0; border: 1px solid var(--pi-border-muted); border-radius: 999px; background: var(--pi-bg); color: var(--pi-muted); opacity: .75; cursor: pointer; }
+ .edge-button:hover, .edge-button:focus-visible { color: var(--pi-text); background: var(--pi-surface-hover); opacity: 1; }
+ :host([side="navigation"][collapsed]) .edge-button { transform: translateX(calc(50% - .5px)); }
+ :host([side="workspace"][collapsed]) .edge-button { transform: translateX(calc(-50% + .5px)); }
+ .edge-icon { width: 12px; height: 12px; fill: none; stroke: currentColor; stroke-width: 2.2; stroke-linecap: round; stroke-linejoin: round; pointer-events: none; }
+ @media (max-width: 1180px) {
+ :host([side="navigation"]) { grid-row: 1 / 3; }
+ :host([side="workspace"]) { display: none; }
+ }
+ @media (max-width: 760px) {
+ :host([side="navigation"]) { display: none; }
+ }
+ `;
+}
diff --git a/src/client/src/components/appShell/AppRefreshControl.ts b/src/client/src/components/appShell/AppRefreshControl.ts
new file mode 100644
index 0000000..624abbd
--- /dev/null
+++ b/src/client/src/components/appShell/AppRefreshControl.ts
@@ -0,0 +1,151 @@
+import { LitElement, css, html } from "lit";
+import { customElement, property, state } from "lit/decorators.js";
+import { actionMenuPanelStyle } from "../actionMenu";
+
+const REFRESH_LONG_PRESS_MS = 550;
+
+@customElement("app-refresh-control")
+export class AppRefreshControl extends LitElement {
+ @property({ type: Boolean }) isRefreshing = false;
+ @property({ attribute: false }) onRefresh?: () => void | Promise;
+ @property({ attribute: false }) onReload?: () => void;
+ @state() private menuOpen = false;
+ @state() private menuStyle = "";
+ private longPressTimer: number | undefined;
+ private suppressNextClick = false;
+
+ override connectedCallback(): void {
+ super.connectedCallback();
+ document.addEventListener("click", this.onDocumentClick);
+ document.addEventListener("keydown", this.onDocumentKeyDown);
+ }
+
+ override disconnectedCallback(): void {
+ document.removeEventListener("click", this.onDocumentClick);
+ document.removeEventListener("keydown", this.onDocumentKeyDown);
+ this.clearLongPressTimer();
+ super.disconnectedCallback();
+ }
+
+ override render() {
+ const label = this.isRefreshing ? "Refreshing app data. Long-press for reload options." : "Refresh app data. Long-press for reload options.";
+ return html`
+
+ ${this.renderMenu()}
+ `;
+ }
+
+ private renderMenu() {
+ if (!this.menuOpen) return null;
+ return html`
+ = {}) {
deleteWorkspace: vi.fn(() => { calls.push("deleteWorkspace"); }),
startSession: vi.fn(() => { calls.push("startSession"); }),
archiveSession: vi.fn(() => { calls.push("archiveSession"); }),
+ deleteCachedNewSession: vi.fn(() => { calls.push("deleteCachedNewSession"); }),
stopActiveWork: vi.fn(() => { calls.push("stopActiveWork"); }),
};
return { context, calls };
@@ -102,6 +104,34 @@ describe("PluginRegistry", () => {
expect(calls).toEqual(["deleteWorkspace"]);
});
+ it("offers archive only for persisted sessions and delete only for browser-cached new sessions", () => {
+ const registry = new PluginRegistry();
+ registry.register({ id: "core", plugin: corePlugin });
+
+ const persistedActions = registry.getActions(createContext({ selectedSession: testSession() }).context);
+ expect(persistedActions.find((action) => action.id === "core:session.archive")?.enabled).toBe(true);
+ expect(persistedActions.find((action) => action.id === "core:session.delete")?.enabled).toBe(false);
+
+ const cachedActions = registry.getActions(createContext({ selectedSession: markCachedNewSessionInfo(testSession()) }).context);
+ expect(cachedActions.find((action) => action.id === "core:session.archive")?.enabled).toBe(false);
+ expect(cachedActions.find((action) => action.id === "core:session.delete")?.enabled).toBe(true);
+
+ const archivedActions = registry.getActions(createContext({ selectedSession: { ...testSession(), archived: true, archivedAt: "2026-05-20T00:00:00.000Z" } }).context);
+ expect(archivedActions.find((action) => action.id === "core:session.archive")?.enabled).toBe(false);
+ expect(archivedActions.find((action) => action.id === "core:session.delete")?.enabled).toBe(false);
+ });
+
+ it("routes browser-cached new session delete through the runtime context", () => {
+ const registry = new PluginRegistry();
+ registry.register({ id: "core", plugin: corePlugin });
+ const { context, calls } = createContext({ selectedSession: markCachedNewSessionInfo(testSession()) });
+ const action = registry.getActions(context).find((candidate) => candidate.id === "core:session.delete");
+
+ if (action !== undefined) void action.run();
+
+ expect(calls).toEqual(["deleteCachedNewSession"]);
+ });
+
it("routes refresh current to the active core workspace panel", () => {
const registry = new PluginRegistry();
registry.register({ id: "core", plugin: corePlugin });
@@ -237,6 +267,19 @@ function testWorkspace(patch: Partial = {}): Workspace {
return { id: "w1", projectId: "p1", path: "/tmp/project", label: "main", isMain: true, isGitRepo: true, isGitWorktree: false, ...patch };
}
+function testSession(patch: Partial = {}): SessionInfo {
+ return {
+ id: "s1",
+ path: "/tmp/s1.jsonl",
+ cwd: "/tmp/project",
+ created: "2026-05-20T00:00:00.000Z",
+ modified: "2026-05-20T00:00:00.000Z",
+ messageCount: 1,
+ firstMessage: "Hello",
+ ...patch,
+ };
+}
+
function testThemeTokens(): ThemeTokens {
return {
"--pi-bg": "#000000",
diff --git a/src/client/src/plugins/types.ts b/src/client/src/plugins/types.ts
index e4f82a7..d9f2588 100644
--- a/src/client/src/plugins/types.ts
+++ b/src/client/src/plugins/types.ts
@@ -70,6 +70,7 @@ export interface PluginRuntimeContext {
deleteWorkspace: (workspace?: Workspace) => void | Promise;
startSession: () => void | Promise;
archiveSession: () => void | Promise;
+ deleteCachedNewSession: () => void | Promise;
stopActiveWork: () => void | Promise;
}
diff --git a/src/client/src/terminalKeys.test.ts b/src/client/src/terminalKeys.test.ts
new file mode 100644
index 0000000..93eee20
--- /dev/null
+++ b/src/client/src/terminalKeys.test.ts
@@ -0,0 +1,43 @@
+import { describe, expect, it } from "vitest";
+import { terminalSoftKeySequence, type TerminalModesSnapshot } from "./terminalKeys";
+
+describe("terminalSoftKeySequence", () => {
+ it("maps common control keys to terminal bytes", () => {
+ expect(terminalSoftKeySequence("escape")).toBe("\x1b");
+ expect(terminalSoftKeySequence("tab")).toBe("\t");
+ expect(terminalSoftKeySequence("ctrl-c")).toBe("\x03");
+ expect(terminalSoftKeySequence("ctrl-d")).toBe("\x04");
+ expect(terminalSoftKeySequence("ctrl-z")).toBe("\x1a");
+ expect(terminalSoftKeySequence("ctrl-l")).toBe("\x0c");
+ expect(terminalSoftKeySequence("ctrl-r")).toBe("\x12");
+ });
+
+ it("maps navigation keys to xterm-compatible sequences", () => {
+ expect(terminalSoftKeySequence("arrow-up")).toBe("\x1b[A");
+ expect(terminalSoftKeySequence("arrow-down")).toBe("\x1b[B");
+ expect(terminalSoftKeySequence("arrow-right")).toBe("\x1b[C");
+ expect(terminalSoftKeySequence("arrow-left")).toBe("\x1b[D");
+ expect(terminalSoftKeySequence("home")).toBe("\x1b[H");
+ expect(terminalSoftKeySequence("end")).toBe("\x1b[F");
+ expect(terminalSoftKeySequence("page-up")).toBe("\x1b[5~");
+ expect(terminalSoftKeySequence("page-down")).toBe("\x1b[6~");
+ expect(terminalSoftKeySequence("delete")).toBe("\x1b[3~");
+ expect(terminalSoftKeySequence("backspace")).toBe("\x7f");
+ });
+
+ it("respects application cursor key mode", () => {
+ const applicationCursorMode: TerminalModesSnapshot = { applicationCursorKeysMode: true };
+
+ expect(terminalSoftKeySequence("arrow-up", applicationCursorMode)).toBe("\x1bOA");
+ expect(terminalSoftKeySequence("arrow-down", applicationCursorMode)).toBe("\x1bOB");
+ expect(terminalSoftKeySequence("arrow-right", applicationCursorMode)).toBe("\x1bOC");
+ expect(terminalSoftKeySequence("arrow-left", applicationCursorMode)).toBe("\x1bOD");
+ expect(terminalSoftKeySequence("home", applicationCursorMode)).toBe("\x1bOH");
+ expect(terminalSoftKeySequence("end", applicationCursorMode)).toBe("\x1bOF");
+ });
+
+ it("maps meta word movement to escape-prefixed sequences", () => {
+ expect(terminalSoftKeySequence("meta-backward-word")).toBe("\x1bb");
+ expect(terminalSoftKeySequence("meta-forward-word")).toBe("\x1bf");
+ });
+});
diff --git a/src/client/src/terminalKeys.ts b/src/client/src/terminalKeys.ts
new file mode 100644
index 0000000..2bdbf6e
--- /dev/null
+++ b/src/client/src/terminalKeys.ts
@@ -0,0 +1,98 @@
+export type TerminalSoftKeyId =
+ | "escape"
+ | "tab"
+ | "ctrl-c"
+ | "ctrl-d"
+ | "ctrl-z"
+ | "ctrl-l"
+ | "ctrl-r"
+ | "ctrl-u"
+ | "ctrl-w"
+ | "arrow-left"
+ | "arrow-up"
+ | "arrow-down"
+ | "arrow-right"
+ | "home"
+ | "end"
+ | "page-up"
+ | "page-down"
+ | "delete"
+ | "backspace"
+ | "meta-backward-word"
+ | "meta-forward-word";
+
+export interface TerminalModesSnapshot {
+ applicationCursorKeysMode: boolean;
+}
+
+export interface TerminalSoftKeyDefinition {
+ id: TerminalSoftKeyId;
+ label: string;
+ ariaLabel: string;
+ title: string;
+}
+
+export const TERMINAL_SOFT_KEYS: readonly TerminalSoftKeyDefinition[] = [
+ { id: "escape", label: "Esc", ariaLabel: "Escape", title: "Send Escape" },
+ { id: "tab", label: "Tab", ariaLabel: "Tab", title: "Send Tab" },
+ { id: "ctrl-c", label: "Ctrl+C", ariaLabel: "Control C", title: "Interrupt the foreground process" },
+ { id: "ctrl-d", label: "Ctrl+D", ariaLabel: "Control D", title: "Send EOF / close input" },
+ { id: "ctrl-z", label: "Ctrl+Z", ariaLabel: "Control Z", title: "Suspend the foreground process" },
+ { id: "ctrl-l", label: "Ctrl+L", ariaLabel: "Control L", title: "Clear / redraw the terminal" },
+ { id: "ctrl-r", label: "Ctrl+R", ariaLabel: "Control R", title: "Reverse search history" },
+ { id: "ctrl-u", label: "Ctrl+U", ariaLabel: "Control U", title: "Delete to the start of the line" },
+ { id: "ctrl-w", label: "Ctrl+W", ariaLabel: "Control W", title: "Delete the previous word" },
+ { id: "arrow-left", label: "←", ariaLabel: "Left arrow", title: "Move left" },
+ { id: "arrow-up", label: "↑", ariaLabel: "Up arrow", title: "Move up / previous command" },
+ { id: "arrow-down", label: "↓", ariaLabel: "Down arrow", title: "Move down / next command" },
+ { id: "arrow-right", label: "→", ariaLabel: "Right arrow", title: "Move right" },
+ { id: "home", label: "Home", ariaLabel: "Home", title: "Move to the start" },
+ { id: "end", label: "End", ariaLabel: "End", title: "Move to the end" },
+ { id: "page-up", label: "PgUp", ariaLabel: "Page up", title: "Page up" },
+ { id: "page-down", label: "PgDn", ariaLabel: "Page down", title: "Page down" },
+ { id: "delete", label: "Del", ariaLabel: "Delete", title: "Delete forward" },
+ { id: "backspace", label: "⌫", ariaLabel: "Backspace", title: "Backspace" },
+ { id: "meta-backward-word", label: "M-B", ariaLabel: "Meta B", title: "Move backward one word" },
+ { id: "meta-forward-word", label: "M-F", ariaLabel: "Meta F", title: "Move forward one word" },
+];
+
+const ESC = "\x1b";
+const DEL = "\x7f";
+
+export function terminalSoftKeySequence(key: TerminalSoftKeyId, modes?: TerminalModesSnapshot): string {
+ switch (key) {
+ case "escape": return ESC;
+ case "tab": return "\t";
+ case "ctrl-c": return controlSequence("c");
+ case "ctrl-d": return controlSequence("d");
+ case "ctrl-z": return controlSequence("z");
+ case "ctrl-l": return controlSequence("l");
+ case "ctrl-r": return controlSequence("r");
+ case "ctrl-u": return controlSequence("u");
+ case "ctrl-w": return controlSequence("w");
+ case "arrow-left": return cursorSequence("D", modes);
+ case "arrow-up": return cursorSequence("A", modes);
+ case "arrow-down": return cursorSequence("B", modes);
+ case "arrow-right": return cursorSequence("C", modes);
+ case "home": return cursorEndpointSequence("H", modes);
+ case "end": return cursorEndpointSequence("F", modes);
+ case "page-up": return `${ESC}[5~`;
+ case "page-down": return `${ESC}[6~`;
+ case "delete": return `${ESC}[3~`;
+ case "backspace": return DEL;
+ case "meta-backward-word": return `${ESC}b`;
+ case "meta-forward-word": return `${ESC}f`;
+ }
+}
+
+function controlSequence(letter: string): string {
+ return String.fromCharCode(letter.toUpperCase().charCodeAt(0) - 64);
+}
+
+function cursorSequence(code: "A" | "B" | "C" | "D", modes: TerminalModesSnapshot | undefined): string {
+ return modes?.applicationCursorKeysMode === true ? `${ESC}O${code}` : `${ESC}[${code}`;
+}
+
+function cursorEndpointSequence(code: "F" | "H", modes: TerminalModesSnapshot | undefined): string {
+ return modes?.applicationCursorKeysMode === true ? `${ESC}O${code}` : `${ESC}[${code}`;
+}
diff --git a/src/client/src/terminalSoftKeysPreference.test.ts b/src/client/src/terminalSoftKeysPreference.test.ts
new file mode 100644
index 0000000..0890119
--- /dev/null
+++ b/src/client/src/terminalSoftKeysPreference.test.ts
@@ -0,0 +1,70 @@
+import { describe, expect, it } from "vitest";
+import {
+ parseTerminalSoftKeysPreference,
+ readTerminalSoftKeysPreference,
+ terminalSoftKeysEnabled,
+ TERMINAL_SOFT_KEYS_STORAGE_KEY,
+ writeTerminalSoftKeysPreference,
+} from "./terminalSoftKeysPreference";
+
+describe("terminal soft key preferences", () => {
+ it("uses stored preferences before environment defaults", () => {
+ expect(terminalSoftKeysEnabled(true, false)).toBe(true);
+ expect(terminalSoftKeysEnabled(false, true)).toBe(false);
+ expect(terminalSoftKeysEnabled(undefined, true)).toBe(true);
+ expect(terminalSoftKeysEnabled(undefined, false)).toBe(false);
+ });
+
+ it("parses boolean local storage values", () => {
+ expect(parseTerminalSoftKeysPreference("true")).toBe(true);
+ expect(parseTerminalSoftKeysPreference("false")).toBe(false);
+ expect(parseTerminalSoftKeysPreference(null)).toBeUndefined();
+ expect(parseTerminalSoftKeysPreference("yes")).toBeUndefined();
+ });
+
+ it("reads and writes the stored preference", () => {
+ const storage = new FakeStorage();
+
+ expect(readTerminalSoftKeysPreference(storage)).toBeUndefined();
+ writeTerminalSoftKeysPreference(true, storage);
+ expect(storage.value(TERMINAL_SOFT_KEYS_STORAGE_KEY)).toBe("true");
+ expect(readTerminalSoftKeysPreference(storage)).toBe(true);
+
+ writeTerminalSoftKeysPreference(false, storage);
+ expect(storage.value(TERMINAL_SOFT_KEYS_STORAGE_KEY)).toBe("false");
+ expect(readTerminalSoftKeysPreference(storage)).toBe(false);
+ });
+
+ it("ignores storage failures", () => {
+ const storage = new ThrowingStorage();
+
+ expect(readTerminalSoftKeysPreference(storage)).toBeUndefined();
+ expect(() => { writeTerminalSoftKeysPreference(true, storage); }).not.toThrow();
+ });
+});
+
+class FakeStorage {
+ private readonly values = new Map();
+
+ getItem(key: string): string | null {
+ return this.values.get(key) ?? null;
+ }
+
+ setItem(key: string, value: string): void {
+ this.values.set(key, value);
+ }
+
+ value(key: string): string | undefined {
+ return this.values.get(key);
+ }
+}
+
+class ThrowingStorage {
+ getItem(): string | null {
+ throw new Error("blocked");
+ }
+
+ setItem(): void {
+ throw new Error("blocked");
+ }
+}
diff --git a/src/client/src/terminalSoftKeysPreference.ts b/src/client/src/terminalSoftKeysPreference.ts
new file mode 100644
index 0000000..6474b38
--- /dev/null
+++ b/src/client/src/terminalSoftKeysPreference.ts
@@ -0,0 +1,57 @@
+export const TERMINAL_SOFT_KEYS_STORAGE_KEY = "pi-web.terminal.softKeys";
+export const TERMINAL_SOFT_KEYS_DEFAULT_ENVIRONMENT_MEDIA = "(pointer: coarse), (max-width: 760px)";
+
+export type TerminalSoftKeysStorage = Pick;
+
+export function terminalSoftKeysEnabled(preference: boolean | undefined, defaultEnabled: boolean): boolean {
+ return preference ?? defaultEnabled;
+}
+
+export function parseTerminalSoftKeysPreference(value: string | null): boolean | undefined {
+ if (value === "true") return true;
+ if (value === "false") return false;
+ return undefined;
+}
+
+export function createTerminalSoftKeysDefaultEnvironmentMedia(): MediaQueryList | undefined {
+ return typeof window !== "undefined" && "matchMedia" in window ? window.matchMedia(TERMINAL_SOFT_KEYS_DEFAULT_ENVIRONMENT_MEDIA) : undefined;
+}
+
+export function isTerminalSoftKeysDefaultEnvironment(media: MediaQueryList | undefined): boolean {
+ return media?.matches === true;
+}
+
+export function initialTerminalSoftKeysEnabled(media = createTerminalSoftKeysDefaultEnvironmentMedia()): boolean {
+ return terminalSoftKeysEnabled(readTerminalSoftKeysPreference(), isTerminalSoftKeysDefaultEnvironment(media));
+}
+
+export function hasTerminalSoftKeysPreference(): boolean {
+ return readTerminalSoftKeysPreference() !== undefined;
+}
+
+export function readTerminalSoftKeysPreference(storage = browserStorage()): boolean | undefined {
+ if (storage === undefined) return undefined;
+ try {
+ return parseTerminalSoftKeysPreference(storage.getItem(TERMINAL_SOFT_KEYS_STORAGE_KEY));
+ } catch {
+ return undefined;
+ }
+}
+
+export function writeTerminalSoftKeysPreference(enabled: boolean, storage = browserStorage()): void {
+ if (storage === undefined) return;
+ try {
+ storage.setItem(TERMINAL_SOFT_KEYS_STORAGE_KEY, String(enabled));
+ } catch {
+ // Ignore storage failures; the per-page toggle still works for this session.
+ }
+}
+
+function browserStorage(): TerminalSoftKeysStorage | undefined {
+ if (typeof window === "undefined") return undefined;
+ try {
+ return window.localStorage;
+ } catch {
+ return undefined;
+ }
+}
diff --git a/src/piWebVersionReport.ts b/src/piWebVersionReport.ts
new file mode 100644
index 0000000..d3567ca
--- /dev/null
+++ b/src/piWebVersionReport.ts
@@ -0,0 +1,239 @@
+import { readFileSync } from "node:fs";
+import { dirname, join } from "node:path";
+import { fileURLToPath } from "node:url";
+import { effectivePiWebConfig } from "./config.js";
+import { SessionDaemonClient } from "./sessiond/sessionDaemonClient.js";
+import type { PiWebComponentStatus, PiWebInstallationInfo, PiWebVersionResponse } from "./shared/apiTypes.js";
+import { parsePiWebComponentStatus, parsePiWebVersionResponse } from "./shared/piWebStatusParsing.js";
+
+const PI_WEB_PACKAGE_NAME = "@jmfederico/pi-web";
+const PI_WEB_VERSION_TIMEOUT_MS = 2000;
+const PI_WEB_VERSION_ENDPOINT_PATH = "/api/pi-web/version";
+const PI_WEB_STATUS_ENDPOINT_PATH = "/api/pi-web/status";
+const DEFAULT_PACKAGE_VERSION = "0.0.0-dev";
+
+interface PackageInfo {
+ name: string;
+ version: string;
+ path: string;
+}
+
+interface RunningVersionInfo {
+ generatedAt?: string;
+ web?: PiWebComponentStatus;
+ sessiond?: PiWebComponentStatus;
+ webError?: string;
+ sessiondError?: string;
+}
+
+export function packageVersion(): string {
+ return readPackageInfo()?.version ?? DEFAULT_PACKAGE_VERSION;
+}
+
+export async function printPiWebVersionReport(): Promise {
+ console.log("PI WEB version");
+ printInstalledPackageVersions();
+ printRunningVersionInfo(await collectRunningVersionInfo());
+}
+
+function packageRootPath(): string {
+ return dirname(dirname(fileURLToPath(import.meta.url)));
+}
+
+function packageJsonPath(): string {
+ return join(packageRootPath(), "package.json");
+}
+
+function readPackageInfo(): PackageInfo | undefined {
+ const path = packageJsonPath();
+ try {
+ return parsePackageInfo(JSON.parse(readFileSync(path, "utf8")), path);
+ } catch {
+ return undefined;
+ }
+}
+
+function parsePackageInfo(value: unknown, path: string): PackageInfo | undefined {
+ if (!isRecord(value)) return undefined;
+ const name = value["name"];
+ const version = value["version"];
+ if (typeof name !== "string" || name === "" || typeof version !== "string" || version === "") return undefined;
+ return { name, version, path };
+}
+
+function webVersionEndpoint(): { endpoint?: string; error?: string } {
+ try {
+ const { config } = effectivePiWebConfig();
+ const host = httpClientHost(config.host);
+ const port = config.port ?? 8504;
+ return { endpoint: `http://${urlHost(host)}:${String(port)}${PI_WEB_VERSION_ENDPOINT_PATH}` };
+ } catch (error) {
+ return { error: `could not read PI WEB config: ${errorMessage(error)}` };
+ }
+}
+
+function httpClientHost(configuredHost: string | undefined): string {
+ const host = configuredHost === undefined || configuredHost === "" ? "127.0.0.1" : configuredHost;
+ if (host === "0.0.0.0" || host === "::" || host === "[::]") return "127.0.0.1";
+ return host;
+}
+
+function urlHost(host: string): string {
+ if (host.startsWith("[") || !host.includes(":")) return host;
+ return `[${host}]`;
+}
+
+function statusEndpointFor(versionEndpoint: string): string {
+ if (!versionEndpoint.endsWith(PI_WEB_VERSION_ENDPOINT_PATH)) return versionEndpoint;
+ return `${versionEndpoint.slice(0, -PI_WEB_VERSION_ENDPOINT_PATH.length)}${PI_WEB_STATUS_ENDPOINT_PATH}`;
+}
+
+async function fetchPiWebVersionResponse(endpoint: string): Promise {
+ const response = await fetch(endpoint, {
+ headers: { accept: "application/json" },
+ signal: AbortSignal.timeout(PI_WEB_VERSION_TIMEOUT_MS),
+ });
+ if (!response.ok) throw new Error(`HTTP ${String(response.status)}`);
+ const parsed: unknown = await response.json();
+ const status = parsePiWebVersionResponse(parsed);
+ if (status === undefined) throw new Error("response did not include PI WEB version information");
+ return status;
+}
+
+async function collectRunningVersionInfo(): Promise {
+ const endpoint = webVersionEndpoint();
+ if (endpoint.endpoint !== undefined) {
+ try {
+ const status = await fetchPiWebVersionResponse(endpoint.endpoint);
+ return { generatedAt: status.generatedAt, web: status.components.web, sessiond: status.components.sessiond };
+ } catch (error) {
+ let webError = `${endpoint.endpoint}: ${errorMessage(error)}`;
+ const statusEndpoint = statusEndpointFor(endpoint.endpoint);
+ if (statusEndpoint !== endpoint.endpoint && isHttpNotFound(error)) {
+ try {
+ const status = await fetchPiWebVersionResponse(statusEndpoint);
+ return { generatedAt: status.generatedAt, web: status.components.web, sessiond: status.components.sessiond };
+ } catch (statusError) {
+ webError = `${webError}; ${statusEndpoint}: ${errorMessage(statusError)}`;
+ }
+ }
+ return runningVersionInfoWithSessiondFallback({ webError });
+ }
+ }
+
+ return runningVersionInfoWithSessiondFallback({ webError: endpoint.error ?? "web/API status endpoint unavailable" });
+}
+
+async function runningVersionInfoWithSessiondFallback(base: { webError: string }): Promise {
+ const sessiond = await collectRunningSessiondInfo();
+ return {
+ webError: base.webError,
+ ...(sessiond.component === undefined ? {} : { sessiond: sessiond.component }),
+ ...(sessiond.error === undefined ? {} : { sessiondError: sessiond.error }),
+ };
+}
+
+async function collectRunningSessiondInfo(): Promise<{ component?: PiWebComponentStatus; error?: string }> {
+ try {
+ const response = await withTimeout(
+ new SessionDaemonClient().request("GET", "/health"),
+ PI_WEB_VERSION_TIMEOUT_MS,
+ "session daemon health check timed out",
+ );
+ if (response.statusCode < 200 || response.statusCode >= 300) throw new Error(`HTTP ${String(response.statusCode)}`);
+ const parsed: unknown = response.body === "" ? undefined : JSON.parse(response.body);
+ const version = isRecord(parsed) ? parsed["version"] : undefined;
+ const component = parsePiWebComponentStatus(version);
+ if (component === undefined) throw new Error("health response did not include version information");
+ return { component };
+ } catch (error) {
+ return { error: errorMessage(error) };
+ }
+}
+
+async function withTimeout(promise: Promise, timeoutMs: number, timeoutMessage: string): Promise {
+ let timeout: ReturnType | undefined;
+ try {
+ return await Promise.race([
+ promise,
+ new Promise((_, reject) => {
+ timeout = setTimeout(() => {
+ reject(new Error(timeoutMessage));
+ }, timeoutMs);
+ }),
+ ]);
+ } finally {
+ if (timeout !== undefined) clearTimeout(timeout);
+ }
+}
+
+function isHttpNotFound(error: unknown): boolean {
+ return error instanceof Error && error.message === "HTTP 404";
+}
+
+function printInstalledPackageVersions(): void {
+ const info = readPackageInfo();
+ console.log("Installed packages:");
+ if (info === undefined) {
+ console.log(`? ${PI_WEB_PACKAGE_NAME}: unknown`);
+ console.log(` missing package metadata: ${packageJsonPath()}`);
+ return;
+ }
+ console.log(`✓ ${info.name}: ${info.version}`);
+ console.log(` ${info.path}`);
+}
+
+function printRunningVersionInfo(info: RunningVersionInfo): void {
+ console.log("Running services:");
+ if (info.web === undefined) printUnavailableComponent("Web/UI", info.webError);
+ else printComponentVersion(info.web);
+ if (info.sessiond === undefined) printUnavailableComponent("Session daemon", info.sessiondError);
+ else printComponentVersion(info.sessiond);
+ if (info.generatedAt !== undefined) console.log(` reported by web/API at ${info.generatedAt}`);
+}
+
+function printComponentVersion(component: PiWebComponentStatus): void {
+ const icon = component.available ? component.stale ? "!" : "✓" : "?";
+ const status = !component.available ? "unavailable" : component.stale ? "restart needed" : "current";
+ console.log(`${icon} ${component.label}: ${status}`);
+ if (component.available || component.runtimeVersion !== undefined || component.installedVersion !== undefined) {
+ console.log(` running: ${formatVersion(component.runtimeVersion)}; installed: ${formatVersion(component.installedVersion)}`);
+ }
+ const installation = installationLabel(component.installation);
+ if (installation !== undefined) console.log(` installation: ${installation}`);
+ if (component.error !== undefined) console.log(` ${component.error}`);
+}
+
+function printUnavailableComponent(label: string, error: string | undefined): void {
+ console.log(`? ${label}: unavailable`);
+ if (error !== undefined && error !== "") console.log(` ${error}`);
+}
+
+function installationLabel(installation: PiWebInstallationInfo | undefined): string | undefined {
+ if (installation === undefined) return undefined;
+ if (installation.kind === "pi-package") {
+ const source = installation.source ?? "Pi package";
+ const scope = installation.scope === undefined ? "" : ` · ${installation.scope}`;
+ const path = installation.path === undefined ? "" : ` · ${installation.path}`;
+ return `${source}${scope}${path}`;
+ }
+ if (installation.kind === "npm-global") {
+ const npmRoot = installation.npmRoot === undefined ? "" : ` · ${installation.npmRoot}`;
+ const path = installation.path === undefined ? "" : ` · ${installation.path}`;
+ return `global npm package${npmRoot}${path}`;
+ }
+ if (installation.kind === "local") return installation.path === undefined ? "local checkout" : `local checkout · ${installation.path}`;
+ return installation.path === undefined ? "installation unknown" : `installation unknown · ${installation.path}`;
+}
+
+function formatVersion(version: string | undefined): string {
+ return version === undefined || version === "" ? "unknown" : version;
+}
+
+function errorMessage(error: unknown): string {
+ return error instanceof Error ? error.message : String(error);
+}
+
+function isRecord(value: unknown): value is Record {
+ return typeof value === "object" && value !== null && !Array.isArray(value);
+}
diff --git a/src/server/app.ts b/src/server/app.ts
index 6432acd..4bfe9fe 100644
--- a/src/server/app.ts
+++ b/src/server/app.ts
@@ -9,13 +9,13 @@ import { ProjectService } from "./projects/projectService.js";
import { WorkspaceService } from "./workspaces/workspaceService.js";
import { listFileSuggestions, listPathSuggestions } from "./workspaces/fileSuggestions.js";
import { listDirectorySuggestions } from "./projects/directorySuggestions.js";
-import { SessionDaemonClient } from "./sessiond/sessionDaemonClient.js";
+import { SessionDaemonClient } from "../sessiond/sessionDaemonClient.js";
import { registerSessionProxyRoutes, type SessionProxyDaemon } from "./sessiond/sessionProxyRoutes.js";
import { registerWorkspaceExplorerRoutes } from "./workspaceExplorerRoutes.js";
import { registerGitRoutes } from "./gitRoutes.js";
import { registerTerminalProxyRoutes } from "./terminalProxyRoutes.js";
import { PiWebPluginService } from "./piWebPluginService.js";
-import { getPiWebStatus } from "./piWebStatus.js";
+import { getPiWebStatus, getPiWebVersionStatus } from "./piWebStatus.js";
import { MachineService } from "./machines/machineService.js";
import { registerMachineRoutes } from "./machines/machineRoutes.js";
import { registerMachineProxyRoutes } from "./machines/machineProxyRoutes.js";
@@ -69,11 +69,11 @@ function registerLocalProjectRoutes(app: FastifyInstance, projects: ProjectServi
}
function registerLocalFileSuggestionRoutes(app: FastifyInstance, prefix: string): void {
- app.get<{ Querystring: { cwd?: string; q?: string; kind?: "tracked" | "untracked" | "other"; mode?: "file" | "path" } }>(`${prefix}/files`, async (request, reply) => {
+ app.get<{ Querystring: { cwd?: string; q?: string; kind?: "tracked" | "untracked" | "other"; mode?: "file" | "path"; scope?: "tracked" | "all" } }>(`${prefix}/files`, async (request, reply) => {
if (request.query.cwd === undefined || request.query.cwd === "") return reply.code(400).send({ error: "cwd query parameter is required" });
try {
if (request.query.mode === "path") return await listPathSuggestions(request.query.cwd, request.query.q ?? "");
- return await listFileSuggestions(request.query.cwd, request.query.q ?? "", request.query.kind);
+ return await listFileSuggestions(request.query.cwd, request.query.q ?? "", { kind: request.query.kind, scope: request.query.scope });
} catch (error) {
return reply.code(400).send({ error: error instanceof Error ? error.message : String(error) });
}
@@ -99,6 +99,7 @@ export async function buildApp(deps: AppDependencies = {}): Promise getPiWebStatus());
+ app.get("/api/pi-web/version", async () => getPiWebVersionStatus());
registerMachineRoutes(app, machines);
diff --git a/src/server/piWebStatus.test.ts b/src/server/piWebStatus.test.ts
index bd6dd42..b31c5da 100644
--- a/src/server/piWebStatus.test.ts
+++ b/src/server/piWebStatus.test.ts
@@ -1,12 +1,17 @@
+import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises";
+import { tmpdir } from "node:os";
+import { join } from "node:path";
import { afterEach, describe, expect, it, vi } from "vitest";
-import { comparePackageVersions, getPiWebStatus } from "./piWebStatus.js";
-import { SessionDaemonClient } from "./sessiond/sessionDaemonClient.js";
+import { comparePackageVersions, getPiWebStatus, getPiWebVersionStatus } from "./piWebStatus.js";
+import { SessionDaemonClient } from "../sessiond/sessionDaemonClient.js";
+import type { PiWebComponentStatus } from "../shared/apiTypes.js";
const originalSkipVersionCheck = process.env["PI_WEB_SKIP_VERSION_CHECK"];
+const originalHome = process.env["HOME"];
afterEach(() => {
- if (originalSkipVersionCheck === undefined) delete process.env["PI_WEB_SKIP_VERSION_CHECK"];
- else process.env["PI_WEB_SKIP_VERSION_CHECK"] = originalSkipVersionCheck;
+ restoreEnv("PI_WEB_SKIP_VERSION_CHECK", originalSkipVersionCheck);
+ restoreEnv("HOME", originalHome);
vi.restoreAllMocks();
});
@@ -17,23 +22,34 @@ describe("PI WEB status", () => {
expect(comparePackageVersions("1.202605.7", "1.202605.8")).toBeLessThan(0);
});
+ it("returns installed and running version components without release metadata", async () => {
+ const daemon = daemonWithComponent({
+ component: "sessiond",
+ label: "Session daemon",
+ runtimeVersion: "1.202605.7",
+ installedVersion: "1.202605.8",
+ stale: true,
+ available: true,
+ });
+
+ const status = await getPiWebVersionStatus(daemon);
+
+ expect(status.packageName).toBe("@jmfederico/pi-web");
+ expect(status.components.web.component).toBe("web");
+ expect(status.components.sessiond.runtimeVersion).toBe("1.202605.7");
+ expect(status).not.toHaveProperty("release");
+ });
+
it("reports stale session daemon versions as messages", async () => {
process.env["PI_WEB_SKIP_VERSION_CHECK"] = "1";
- const daemon = new SessionDaemonClient();
- vi.spyOn(daemon, "request").mockResolvedValue({
- statusCode: 200,
- headers: { "content-type": "application/json" },
- body: JSON.stringify({
- version: {
- component: "sessiond",
- label: "Session daemon",
- runtimeVersion: "1.202605.7",
- installedVersion: "1.202605.8",
- stale: true,
- available: true,
- installation: { kind: "pi-package", source: "npm:@jmfederico/pi-web", scope: "user", path: "/tmp/pi-web" },
- },
- }),
+ const daemon = daemonWithComponent({
+ component: "sessiond",
+ label: "Session daemon",
+ runtimeVersion: "1.202605.7",
+ installedVersion: "1.202605.8",
+ stale: true,
+ available: true,
+ installation: { kind: "pi-package", source: "npm:@jmfederico/pi-web", scope: "user", path: "/tmp/pi-web" },
});
const status = await getPiWebStatus(daemon);
@@ -41,7 +57,81 @@ describe("PI WEB status", () => {
expect(status.release.skipped).toBe(true);
expect(status.components.sessiond.stale).toBe(true);
expect(status.components.sessiond.installation).toMatchObject({ kind: "pi-package", source: "npm:@jmfederico/pi-web", scope: "user" });
- expect(status.commands.update).not.toBe("");
expect(status.messages.map((message) => message.id)).toContain("sessiond-stale");
});
+
+ it("suggests native systemd commands for local development services", async () => {
+ if (process.platform !== "linux") return;
+ process.env["PI_WEB_SKIP_VERSION_CHECK"] = "1";
+ const home = await tempHome();
+ try {
+ process.env["HOME"] = home;
+ await installSystemdServiceFiles(home, ["pi-web-sessiond.service", "pi-web-ui-dev.service"]);
+ const daemon = daemonWithComponent(staleLocalSessiond());
+
+ const status = await getPiWebStatus(daemon);
+
+ expect(status.commands.restart).toBe("systemctl --user restart pi-web-sessiond.service pi-web-ui-dev.service");
+ expect(status.commands.restartWeb).toBe("systemctl --user restart pi-web-ui-dev.service");
+ expect(status.commands.restartSessiond).toBe("systemctl --user restart pi-web-sessiond.service");
+ expect(status.messages.find((message) => message.id === "sessiond-stale")?.command).toBe("systemctl --user restart pi-web-sessiond.service");
+ } finally {
+ await rm(home, { recursive: true, force: true });
+ }
+ });
+
+ it("omits local restart commands when no native service command is known", async () => {
+ process.env["PI_WEB_SKIP_VERSION_CHECK"] = "1";
+ const home = await tempHome();
+ try {
+ process.env["HOME"] = home;
+ const daemon = daemonWithComponent(staleLocalSessiond());
+
+ const status = await getPiWebStatus(daemon);
+ const staleMessage = status.messages.find((message) => message.id === "sessiond-stale");
+
+ expect(status.commands.restart).toBeUndefined();
+ expect(staleMessage?.command).toBeUndefined();
+ expect(JSON.stringify(status)).not.toContain("pi-web restart");
+ } finally {
+ await rm(home, { recursive: true, force: true });
+ }
+ });
});
+
+function daemonWithComponent(component: PiWebComponentStatus): SessionDaemonClient {
+ const daemon = new SessionDaemonClient();
+ vi.spyOn(daemon, "request").mockResolvedValue({
+ statusCode: 200,
+ headers: { "content-type": "application/json" },
+ body: JSON.stringify({ version: component }),
+ });
+ return daemon;
+}
+
+function staleLocalSessiond(): PiWebComponentStatus {
+ return {
+ component: "sessiond",
+ label: "Session daemon",
+ runtimeVersion: "1.202605.7",
+ installedVersion: "1.202605.8",
+ stale: true,
+ available: true,
+ installation: { kind: "local", path: "/srv/dev/pi-web" },
+ };
+}
+
+async function tempHome(): Promise {
+ return await mkdtemp(join(tmpdir(), "pi-web-status-"));
+}
+
+async function installSystemdServiceFiles(home: string, names: string[]): Promise {
+ const dir = join(home, ".config", "systemd", "user");
+ await mkdir(dir, { recursive: true });
+ await Promise.all(names.map((name) => writeFile(join(dir, name), "")));
+}
+
+function restoreEnv(key: string, value: string | undefined): void {
+ if (value === undefined) Reflect.deleteProperty(process.env, key);
+ else process.env[key] = value;
+}
diff --git a/src/server/piWebStatus.ts b/src/server/piWebStatus.ts
index f60039e..b44ee6e 100644
--- a/src/server/piWebStatus.ts
+++ b/src/server/piWebStatus.ts
@@ -1,11 +1,13 @@
import { spawnSync } from "node:child_process";
-import { readFileSync } from "node:fs";
+import { existsSync, readFileSync } from "node:fs";
import { readFile, realpath, stat } from "node:fs/promises";
+import { homedir } from "node:os";
import { dirname, join, relative, resolve, sep } from "node:path";
import { fileURLToPath } from "node:url";
import { DefaultPackageManager, getAgentDir, SettingsManager } from "@earendil-works/pi-coding-agent";
-import type { PiWebComponentStatus, PiWebInstallationInfo, PiWebReleaseStatus, PiWebServiceComponent, PiWebStatusMessage, PiWebStatusResponse } from "../shared/apiTypes.js";
-import { SessionDaemonClient } from "./sessiond/sessionDaemonClient.js";
+import type { PiWebComponentStatus, PiWebInstallationInfo, PiWebReleaseStatus, PiWebServiceComponent, PiWebStatusMessage, PiWebStatusResponse, PiWebVersionResponse } from "../shared/apiTypes.js";
+import { parsePiWebComponentStatus } from "../shared/piWebStatusParsing.js";
+import { SessionDaemonClient } from "../sessiond/sessionDaemonClient.js";
const PI_WEB_PACKAGE_NAME = "@jmfederico/pi-web";
const PI_WEB_NPM_SOURCE = `npm:${PI_WEB_PACKAGE_NAME}`;
@@ -13,12 +15,46 @@ const DEFAULT_VERSION = "0.0.0-dev";
const LATEST_RELEASE_CACHE_MS = 6 * 60 * 60 * 1000;
const VERSION_CHECK_TIMEOUT_MS = 5000;
-const restartCommands = {
- restart: "pi-web restart",
- restartSystemd: "pi-web restart",
- restartDev: "pi-web restart",
+type ServiceId = "sessiond" | "web" | "uiDev";
+type NativeServiceBackendKind = "systemd" | "launchd";
+
+interface NativeServiceRef {
+ id: ServiceId;
+ systemdName: string;
+ launchdLabel: string;
+ launchdPlistName: string;
+}
+
+interface NativeServiceCommands {
+ restart?: string;
+ restartWeb?: string;
+ restartSessiond?: string;
+ status?: string;
+}
+
+const serviceRefs: Record = {
+ sessiond: {
+ id: "sessiond",
+ systemdName: "pi-web-sessiond.service",
+ launchdLabel: "com.pi-web.sessiond",
+ launchdPlistName: "com.pi-web.sessiond.plist",
+ },
+ web: {
+ id: "web",
+ systemdName: "pi-web.service",
+ launchdLabel: "com.pi-web.web",
+ launchdPlistName: "com.pi-web.web.plist",
+ },
+ uiDev: {
+ id: "uiDev",
+ systemdName: "pi-web-ui-dev.service",
+ launchdLabel: "com.pi-web.ui-dev",
+ launchdPlistName: "com.pi-web.ui-dev.plist",
+ },
};
+const startServiceOrder: ServiceId[] = ["sessiond", "web", "uiDev"];
+
interface PackageInfo {
name: string;
version: string;
@@ -47,20 +83,27 @@ export async function getPiWebComponentStatus(component: PiWebServiceComponent):
};
}
-export async function getPiWebStatus(daemon = new SessionDaemonClient()): Promise {
- const web = await getPiWebComponentStatus("web");
- const [installed, sessiond] = await Promise.all([
- readInstalledPackageInfo(),
+export async function getPiWebVersionStatus(daemon = new SessionDaemonClient()): Promise {
+ const [web, sessiond] = await Promise.all([
+ getPiWebComponentStatus("web"),
getSessiondComponentStatus(daemon),
]);
- const release = await getLatestReleaseStatus(installed?.version ?? web.runtimeVersion ?? DEFAULT_VERSION);
- const components = { web, sessiond };
- const commands = commandsFor(web.installation ?? sessiond.installation);
- const messages = buildMessages(components, release, commands);
return {
packageName: PI_WEB_PACKAGE_NAME,
generatedAt: new Date().toISOString(),
- components,
+ components: { web, sessiond },
+ };
+}
+
+export async function getPiWebStatus(daemon = new SessionDaemonClient()): Promise {
+ const versionStatus = await getPiWebVersionStatus(daemon);
+ const { web, sessiond } = versionStatus.components;
+ const release = await getLatestReleaseStatus(web.installedVersion ?? web.runtimeVersion ?? DEFAULT_VERSION);
+ const components = { web, sessiond };
+ const commands = commandsFor(components);
+ const messages = buildMessages(components, release, commands);
+ return {
+ ...versionStatus,
release,
commands,
messages,
@@ -179,54 +222,13 @@ async function getSessiondComponentStatus(daemon: SessionDaemonClient): Promise<
}
const parsed: unknown = upstream.body === "" ? undefined : JSON.parse(upstream.body);
const version = isRecord(parsed) ? parsed["version"] : undefined;
- const component = parseComponentStatus(version);
+ const component = parsePiWebComponentStatus(version);
return component ?? unavailableSessiond("health response did not include version information");
} catch (error) {
return unavailableSessiond(error instanceof Error ? error.message : String(error));
}
}
-function parseComponentStatus(value: unknown): PiWebComponentStatus | undefined {
- if (!isRecord(value)) return undefined;
- const component = value["component"];
- const label = value["label"];
- const runtimeVersion = value["runtimeVersion"];
- const installedVersion = value["installedVersion"];
- const stale = value["stale"];
- const available = value["available"];
- const error = value["error"];
- const installation = parseInstallationInfo(value["installation"]);
- if (component !== "web" && component !== "sessiond") return undefined;
- if (typeof label !== "string" || typeof stale !== "boolean" || typeof available !== "boolean") return undefined;
- return {
- component,
- label,
- ...(typeof runtimeVersion === "string" ? { runtimeVersion } : {}),
- ...(typeof installedVersion === "string" ? { installedVersion } : {}),
- stale,
- available,
- ...(installation === undefined ? {} : { installation }),
- ...(typeof error === "string" ? { error } : {}),
- };
-}
-
-function parseInstallationInfo(value: unknown): PiWebInstallationInfo | undefined {
- if (!isRecord(value)) return undefined;
- const kind = value["kind"];
- const path = value["path"];
- const source = value["source"];
- const scope = value["scope"];
- const npmRoot = value["npmRoot"];
- if (kind !== "pi-package" && kind !== "npm-global" && kind !== "local" && kind !== "unknown") return undefined;
- return {
- kind,
- ...(typeof path === "string" ? { path } : {}),
- ...(typeof source === "string" ? { source } : {}),
- ...(scope === "user" || scope === "project" ? { scope } : {}),
- ...(typeof npmRoot === "string" ? { npmRoot } : {}),
- };
-}
-
function unavailableSessiond(error: string): PiWebComponentStatus {
return {
component: "sessiond",
@@ -280,21 +282,122 @@ async function fetchLatestNpmVersion(currentVersion: string): Promise {
return version;
}
-function commandsFor(installation: PiWebInstallationInfo | undefined): PiWebStatusResponse["commands"] {
+function commandsFor(components: PiWebStatusResponse["components"]): PiWebStatusResponse["commands"] {
+ const installation = preferredInstallation(components);
+ const serviceCommands = nativeServiceCommands();
+ const cliCommands = piWebCliCommands(installation);
+ const restart = restartCommandFor(installation, serviceCommands, cliCommands);
+ const restartWeb = serviceCommands.restartWeb ?? cliCommands.restart;
+ const restartSessiond = serviceCommands.restartSessiond ?? cliCommands.restart;
+ const status = serviceCommands.status ?? cliCommands.status;
+ const update = updateCommandFor(installation, restart);
+
return {
- update: updateCommandFor(installation),
- ...restartCommands,
+ ...(update === undefined ? {} : { update }),
+ ...(restart === undefined ? {} : { restart }),
+ ...(restartWeb === undefined ? {} : { restartWeb }),
+ ...(restartSessiond === undefined ? {} : { restartSessiond }),
+ ...(status === undefined ? {} : { status }),
};
}
-function updateCommandFor(installation: PiWebInstallationInfo | undefined): string {
+function preferredInstallation(components: PiWebStatusResponse["components"]): PiWebInstallationInfo | undefined {
+ const web = components.web.installation;
+ const sessiond = components.sessiond.installation;
+ if (web?.kind === "local" || sessiond?.kind === "local") return web?.kind === "local" ? web : sessiond;
+ return web ?? sessiond;
+}
+
+function piWebCliCommands(installation: PiWebInstallationInfo | undefined): NativeServiceCommands {
+ if (installation?.kind !== "npm-global" || !hasCommand("pi-web")) return {};
+ return { restart: "pi-web restart", status: "pi-web status" };
+}
+
+function restartCommandFor(installation: PiWebInstallationInfo | undefined, serviceCommands: NativeServiceCommands, cliCommands: NativeServiceCommands): string | undefined {
+ if (installation?.kind === "local" || installation?.kind === "pi-package") return serviceCommands.restart ?? cliCommands.restart;
+ return cliCommands.restart ?? serviceCommands.restart;
+}
+
+function updateCommandFor(installation: PiWebInstallationInfo | undefined, restartCommand: string | undefined): string | undefined {
+ if (restartCommand === undefined) return undefined;
if (installation?.kind === "pi-package") {
- return `pi update ${installation.source ?? PI_WEB_NPM_SOURCE} && ${restartCommands.restart}`;
+ if (!hasCommand("pi")) return undefined;
+ return `pi update ${installation.source ?? PI_WEB_NPM_SOURCE} && ${restartCommand}`;
}
if (installation?.kind === "local" && installation.path !== undefined) {
- return `cd ${shellQuote(installation.path)} && git pull && npm install && npm run build && ${restartCommands.restart}`;
+ if (!hasCommand("npm") || !isGitCheckoutWithUpstream(installation.path)) return undefined;
+ return `cd ${shellQuote(installation.path)} && git pull --ff-only && npm install && npm run build && ${restartCommand}`;
}
- return `npm install -g ${PI_WEB_PACKAGE_NAME} && ${restartCommands.restart}`;
+ if (installation?.kind !== "npm-global" || !hasCommand("npm")) return undefined;
+ return `npm install -g ${PI_WEB_PACKAGE_NAME} && ${restartCommand}`;
+}
+
+function nativeServiceCommands(): NativeServiceCommands {
+ const backend = nativeServiceBackend();
+ if (backend === undefined) return {};
+ const installed = installedServiceIds(backend);
+ if (installed.size === 0) return {};
+ const web = installedServiceRefs(installed, ["web", "uiDev"]);
+ const sessiond = installedServiceRefs(installed, ["sessiond"]);
+ const restartable = web.length === 0 ? [] : installedServiceRefs(installed);
+ const status = installedServiceRefs(installed);
+ return {
+ ...(restartable.length === 0 ? {} : { restart: restartNativeServicesCommand(backend, restartable) }),
+ ...(web.length === 0 ? {} : { restartWeb: restartNativeServicesCommand(backend, web) }),
+ ...(sessiond.length === 0 ? {} : { restartSessiond: restartNativeServicesCommand(backend, sessiond) }),
+ ...(status.length === 0 ? {} : { status: statusNativeServicesCommand(backend, status) }),
+ };
+}
+
+function nativeServiceBackend(): NativeServiceBackendKind | undefined {
+ if (process.platform === "linux" && hasCommand("systemctl")) return "systemd";
+ if (process.platform === "darwin" && hasCommand("launchctl")) return "launchd";
+ return undefined;
+}
+
+function installedServiceIds(backend: NativeServiceBackendKind): Set {
+ return new Set(startServiceOrder.filter((id) => existsSync(serviceFilePath(backend, serviceRefs[id]))));
+}
+
+function installedServiceRefs(installed: Set, candidates: ServiceId[] = startServiceOrder): NativeServiceRef[] {
+ return startServiceOrder.filter((id) => candidates.includes(id) && installed.has(id)).map((id) => serviceRefs[id]);
+}
+
+function serviceFilePath(backend: NativeServiceBackendKind, ref: NativeServiceRef): string {
+ return backend === "systemd" ? join(systemdServiceDir(), ref.systemdName) : join(launchdServiceDir(), ref.launchdPlistName);
+}
+
+function systemdServiceDir(): string {
+ return join(homedir(), ".config", "systemd", "user");
+}
+
+function launchdServiceDir(): string {
+ return join(homedir(), "Library", "LaunchAgents");
+}
+
+function restartNativeServicesCommand(backend: NativeServiceBackendKind, refs: NativeServiceRef[]): string {
+ if (backend === "systemd") return `systemctl --user restart ${refs.map((ref) => ref.systemdName).join(" ")}`;
+ return refs.map((ref) => `launchctl kickstart -k gui/$(id -u)/${ref.launchdLabel}`).join(" && ");
+}
+
+function statusNativeServicesCommand(backend: NativeServiceBackendKind, refs: NativeServiceRef[]): string {
+ if (backend === "systemd") return `systemctl --user status ${refs.map((ref) => ref.systemdName).join(" ")}`;
+ return refs.map((ref) => `launchctl print gui/$(id -u)/${ref.launchdLabel}`).join(" && ");
+}
+
+function isGitCheckoutWithUpstream(path: string): boolean {
+ return hasCommand("git")
+ && commandSucceeds("git", ["-C", path, "rev-parse", "--is-inside-work-tree"])
+ && commandSucceeds("git", ["-C", path, "rev-parse", "--abbrev-ref", "--symbolic-full-name", "@{u}"]);
+}
+
+function hasCommand(command: string): boolean {
+ return commandSucceeds("/usr/bin/env", ["sh", "-c", `command -v ${command}`]);
+}
+
+function commandSucceeds(command: string, args: string[]): boolean {
+ const result = spawnSync(command, args, { encoding: "utf8" });
+ return result.status === 0;
}
function shellQuote(value: string): string {
@@ -310,18 +413,23 @@ function buildMessages(components: PiWebStatusResponse["components"], release: P
id: "update-available",
severity: "info",
title: "PI WEB update available",
- body: `PI WEB ${release.latestVersion} is available${installedVersion === undefined ? "" : `; installed version is ${installedVersion}`}. Update PI WEB, then restart PI WEB services.`,
- command: commands.update,
+ body: commands.update === undefined
+ ? `PI WEB ${release.latestVersion} is available${installedVersion === undefined ? "" : `; installed version is ${installedVersion}`}. Update PI WEB, then restart the services or processes for this installation.`
+ : `PI WEB ${release.latestVersion} is available${installedVersion === undefined ? "" : `; installed version is ${installedVersion}`}. Run the update command to update PI WEB and restart its services.`,
+ ...optionalMessageCommand(commands.update),
});
}
if (components.web.stale) {
+ const command = commands.restartWeb ?? commands.restart;
messages.push({
id: "web-stale",
severity: "warning",
title: "Web/UI service restart needed",
- body: `The Web/UI service is running ${formatVersion(components.web.runtimeVersion)}, but ${formatVersion(components.web.installedVersion)} is installed. Restart the service to use the installed version.`,
- command: commands.restart,
+ body: command === undefined
+ ? `The Web/UI service is running ${formatVersion(components.web.runtimeVersion)}, but ${formatVersion(components.web.installedVersion)} is installed. Restart the Web/UI service or process to use the installed version.`
+ : `The Web/UI service is running ${formatVersion(components.web.runtimeVersion)}, but ${formatVersion(components.web.installedVersion)} is installed. Restart the service to use the installed version.`,
+ ...optionalMessageCommand(command),
});
}
@@ -330,22 +438,31 @@ function buildMessages(components: PiWebStatusResponse["components"], release: P
id: "sessiond-unavailable",
severity: "warning",
title: "Session daemon version unavailable",
- body: `PI WEB could not check the session daemon version${components.sessiond.error === undefined ? "." : `: ${components.sessiond.error}`}`,
- command: "pi-web status",
+ body: commands.status === undefined
+ ? `PI WEB could not check the session daemon version${components.sessiond.error === undefined ? "." : `: ${components.sessiond.error}`}. Check the session daemon service or process that runs this installation.`
+ : `PI WEB could not check the session daemon version${components.sessiond.error === undefined ? "." : `: ${components.sessiond.error}`}`,
+ ...optionalMessageCommand(commands.status),
});
} else if (components.sessiond.stale) {
+ const command = commands.restartSessiond ?? commands.restart;
messages.push({
id: "sessiond-stale",
severity: "warning",
title: "Session daemon restart needed",
- body: `The session daemon is running ${formatVersion(components.sessiond.runtimeVersion)}, but ${formatVersion(components.sessiond.installedVersion)} is installed. Restart the daemon to use the installed version.`,
- command: commands.restart,
+ body: command === undefined
+ ? `The session daemon is running ${formatVersion(components.sessiond.runtimeVersion)}, but ${formatVersion(components.sessiond.installedVersion)} is installed. Restart the session daemon service or process to use the installed version.`
+ : `The session daemon is running ${formatVersion(components.sessiond.runtimeVersion)}, but ${formatVersion(components.sessiond.installedVersion)} is installed. Restart the daemon to use the installed version.`,
+ ...optionalMessageCommand(command),
});
}
return messages;
}
+function optionalMessageCommand(command: string | undefined): Pick | object {
+ return command === undefined ? {} : { command };
+}
+
function skipVersionCheck(): boolean {
return ["PI_WEB_SKIP_VERSION_CHECK", "PI_WEB_OFFLINE", "PI_SKIP_VERSION_CHECK", "PI_OFFLINE"].some((key) => {
const value = process.env[key];
diff --git a/src/server/sessiond.ts b/src/server/sessiond.ts
index 4f49a54..7f4b6d9 100644
--- a/src/server/sessiond.ts
+++ b/src/server/sessiond.ts
@@ -10,7 +10,7 @@ import { AuthService } from "./sessions/authService.js";
import { registerAuthRoutes } from "./sessions/authRoutes.js";
import { PiSessionService } from "./sessions/piSessionService.js";
import { registerSessionRoutes } from "./sessions/sessionRoutes.js";
-import { sessiondSocketPath } from "./sessiond/config.js";
+import { sessiondSocketPath } from "../sessiond/config.js";
import { TerminalService } from "./terminals/terminalService.js";
import { registerTerminalRoutes } from "./terminals/terminalRoutes.js";
import { getPiWebComponentStatus } from "./piWebStatus.js";
diff --git a/src/server/sessiond/sessionProxyRoutes.ts b/src/server/sessiond/sessionProxyRoutes.ts
index 7a64630..68b1f55 100644
--- a/src/server/sessiond/sessionProxyRoutes.ts
+++ b/src/server/sessiond/sessionProxyRoutes.ts
@@ -1,6 +1,6 @@
import type { FastifyInstance, FastifyReply } from "fastify";
import { WebSocket, type RawData } from "ws";
-import { SessionDaemonClient } from "./sessionDaemonClient.js";
+import { SessionDaemonClient } from "../../sessiond/sessionDaemonClient.js";
export interface SessionProxyDaemon {
request(method: string, path: string, body?: unknown): Promise<{ statusCode: number; headers: Record; body: string }>;
diff --git a/src/server/sessions/piSessionService.test.ts b/src/server/sessions/piSessionService.test.ts
index 74e3e69..eb23fc1 100644
--- a/src/server/sessions/piSessionService.test.ts
+++ b/src/server/sessions/piSessionService.test.ts
@@ -45,6 +45,7 @@ function sessionRecord(id: string, cwd = "/workspace") {
function fakeRuntime(sessionId = "session-1", patch: Partial = {}) {
const promptCalls: { text: string; options: unknown }[] = [];
+ const listeners: ((event: unknown) => void)[] = [];
const calls = { abort: 0, clearQueue: 0, dispose: 0, prompt: promptCalls };
const session: TestSession = {
sessionId,
@@ -63,7 +64,13 @@ function fakeRuntime(sessionId = "session-1", patch: Partial = {})
extensionRunner: { getRegisteredCommands: () => [] },
promptTemplates: [],
resourceLoader: { getSkills: () => ({ skills: [] }) },
- subscribe: () => () => undefined,
+ subscribe: (listener: (event: unknown) => void) => {
+ listeners.push(listener);
+ return () => {
+ const index = listeners.indexOf(listener);
+ if (index !== -1) listeners.splice(index, 1);
+ };
+ },
getSessionStats: () => ({ sessionId, totalMessages: 0, userMessages: 0, assistantMessages: 0, toolCalls: 0, tokens: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 }, cost: 0 }),
getContextUsage: () => undefined,
prompt: (text: string, options: unknown) => {
@@ -101,7 +108,7 @@ function fakeRuntime(sessionId = "session-1", patch: Partial = {})
return Promise.resolve();
},
};
- return { runtime, session, calls };
+ return { runtime, session, calls, emit: (event: unknown) => { for (const listener of [...listeners]) listener(event); } };
}
function runtimeCreator(runtime: PiSessionRuntime): RuntimeCreator {
@@ -414,6 +421,60 @@ describe("PiSessionService", () => {
await service.dispose();
});
+ it("holds prompts sent during compaction until compaction finishes", async () => {
+ const hub = new CapturingSessionEventHub();
+ const fake = fakeRuntime("compacting-session", { isCompacting: true });
+ let resolveFirstPrompt: (() => void) | undefined;
+ fake.session.prompt = (text: string, options?: { streamingBehavior?: "steer" | "followUp" }) => {
+ fake.calls.prompt.push({ text, options });
+ if (options === undefined) {
+ fake.session.isStreaming = true;
+ return new Promise((resolve) => { resolveFirstPrompt = resolve; });
+ }
+ return Promise.resolve();
+ };
+ const service = new PiSessionService(hub, {
+ createAgentRuntime: runtimeCreator(fake.runtime),
+ sessionManager: sessionGateway([sessionRecord("compacting-session")]),
+ heartbeatIntervalMs: 60_000,
+ });
+
+ await service.prompt("compacting-session", "Start task 1", "followUp");
+ await service.prompt("compacting-session", "Then task 2", "followUp");
+
+ expect(fake.calls.prompt).toEqual([]);
+ expect(hub.sessionEvents.some(({ event }) => event.type === "message.append")).toBe(false);
+ await expect(service.status("compacting-session")).resolves.toMatchObject({
+ pendingMessageCount: 2,
+ queuedMessages: [{ kind: "followUp", text: "Start task 1" }, { kind: "followUp", text: "Then task 2" }],
+ });
+
+ fake.session.isCompacting = false;
+ fake.emit({ type: "compaction_end" });
+ await new Promise((resolve) => setTimeout(resolve, 5));
+
+ expect(fake.calls.prompt).toEqual([{ text: "Start task 1", options: undefined }]);
+ expect(hub.sessionEvents.some(({ event }) => event.type === "message.append" && JSON.stringify(event.message).includes("Start task 1"))).toBe(true);
+ await expect(service.status("compacting-session")).resolves.toMatchObject({
+ pendingMessageCount: 1,
+ queuedMessages: [{ kind: "followUp", text: "Then task 2" }],
+ });
+
+ fake.emit({ type: "agent_start" });
+ await new Promise((resolve) => setTimeout(resolve, 5));
+
+ expect(fake.calls.prompt).toEqual([
+ { text: "Start task 1", options: undefined },
+ { text: "Then task 2", options: { streamingBehavior: "followUp" } },
+ ]);
+ await expect(service.status("compacting-session")).resolves.toMatchObject({
+ pendingMessageCount: 0,
+ queuedMessages: [],
+ });
+ resolveFirstPrompt?.();
+ await service.dispose();
+ });
+
it("clears queued messages when aborting active work", async () => {
const fake = fakeRuntime("abort-session");
const service = new PiSessionService(new CapturingSessionEventHub(), {
@@ -430,6 +491,24 @@ describe("PiSessionService", () => {
await service.dispose();
});
+ it("clears prompts queued during compaction when aborting active work", async () => {
+ const fake = fakeRuntime("abort-compaction-session", { isCompacting: true });
+ const service = new PiSessionService(new CapturingSessionEventHub(), {
+ createAgentRuntime: runtimeCreator(fake.runtime),
+ sessionManager: sessionGateway([sessionRecord("abort-compaction-session")]),
+ heartbeatIntervalMs: 60_000,
+ });
+
+ await service.prompt("abort-compaction-session", "Do not deliver after abort", "followUp");
+ await expect(service.status("abort-compaction-session")).resolves.toMatchObject({ pendingMessageCount: 1 });
+ await service.abort("abort-compaction-session");
+
+ expect(fake.calls.clearQueue).toBe(1);
+ expect(fake.calls.prompt).toEqual([]);
+ await expect(service.status("abort-compaction-session")).resolves.toMatchObject({ pendingMessageCount: 0, queuedMessages: [] });
+ await service.dispose();
+ });
+
it("refreshes auth state and dedupes warnings when logout removes the current model's credentials", async () => {
const hub = new CapturingSessionEventHub();
const authStorage = AuthStorage.inMemory({ anthropic: { type: "api_key", key: "sk-test" } });
diff --git a/src/server/sessions/piSessionService.ts b/src/server/sessions/piSessionService.ts
index 93e9d64..cdb29ed 100644
--- a/src/server/sessions/piSessionService.ts
+++ b/src/server/sessions/piSessionService.ts
@@ -34,6 +34,13 @@ function authLossWarningKey(sessionId: string, provider: string, modelId: string
return `${sessionId}:${provider}/${modelId}`;
}
+type QueuedPromptKind = "steer" | "followUp";
+
+interface QueuedPrompt {
+ kind: QueuedPromptKind;
+ text: string;
+}
+
type SessionArchiveRepository = Pick;
interface PiSessionListEntry {
id: string;
@@ -180,6 +187,8 @@ export class PiSessionService {
private readonly activities = new Map();
private readonly heartbeat: NodeJS.Timeout;
private readonly commandService: SessionCommandService;
+ private readonly compactionPromptQueues = new Map();
+ private readonly compactionDrainTimers = new Map();
private readonly authLossWarnings = new Set();
private readonly archiveStore: SessionArchiveRepository;
private readonly agentDir: string;
@@ -222,9 +231,11 @@ export class PiSessionService {
async dispose(): Promise {
clearInterval(this.heartbeat);
+ this.clearCompactionDrainTimers();
const activeSessions = Array.from(new Set(this.active.values()));
this.active.clear();
this.activities.clear();
+ this.compactionPromptQueues.clear();
this.authLossWarnings.clear();
await Promise.all(activeSessions.map(async (active) => {
active.unsubscribe();
@@ -355,18 +366,36 @@ export class PiSessionService {
this.maybeGenerateSessionName(session, text);
const isQueued = session.isStreaming || session.isCompacting;
const behavior = isQueued ? streamingBehavior ?? "followUp" : undefined;
- if (isQueued && hasQueuedMessageText(session, text)) {
+ if (isQueued && this.hasQueuedMessageText(session, text)) {
this.publishActivity(session, "duplicate queued message ignored", "active");
this.publishStatus(session);
return;
}
- this.publishActivity(session, session.isCompacting ? "message queued during compaction" : behavior === "steer" ? "steering queued" : behavior === "followUp" ? "message queued" : "prompt accepted", "active");
- if (!isQueued) this.events.publish(sessionId, { type: "message.append", message: userTextMessage(text) });
- void session.prompt(text, behavior === undefined ? undefined : { streamingBehavior: behavior }).catch((error: unknown) => {
+ if (session.isCompacting) {
+ this.enqueuePromptDuringCompaction(session, text, behavior ?? "followUp");
+ return;
+ }
+ void this.submitPrompt(session, text, behavior);
+ }
+
+ private submitPrompt(session: PiAgentSession, text: string, behavior: QueuedPromptKind | undefined): Promise {
+ this.publishActivity(session, behavior === "steer" ? "steering queued" : behavior === "followUp" ? "message queued" : "prompt accepted", "active");
+ if (behavior === undefined) this.events.publish(session.sessionId, { type: "message.append", message: userTextMessage(text) });
+ const promptPromise = session.prompt(text, behavior === undefined ? undefined : { streamingBehavior: behavior }).catch((error: unknown) => {
const message = error instanceof Error ? error.message : String(error);
this.publishActivity(session, "error", "error", message);
- this.events.publish(sessionId, { type: "session.error", message });
+ this.events.publish(session.sessionId, { type: "session.error", message });
});
+ void promptPromise;
+ return promptPromise;
+ }
+
+ private enqueuePromptDuringCompaction(session: PiAgentSession, text: string, kind: QueuedPromptKind): void {
+ const queue = this.compactionPromptQueues.get(session.sessionId) ?? [];
+ queue.push({ kind, text });
+ this.compactionPromptQueues.set(session.sessionId, queue);
+ this.publishActivity(session, "message queued during compaction", "active");
+ this.publishStatus(session);
}
async shell(sessionId: string, text: string): Promise {
@@ -416,7 +445,7 @@ export class PiSessionService {
async archive(sessionId: string): Promise {
const session = await this.getOrOpen(sessionId);
- if (sessionHasActiveWork(session)) throw new Error("Stop current session activity before archiving");
+ if (this.hasActiveWork(session)) throw new Error("Stop current session activity before archiving");
const archiveInput = await this.archiveInputForSession(session);
await this.closeActive(session.sessionId);
await this.archiveStore.archive(archiveInput);
@@ -427,7 +456,7 @@ export class PiSessionService {
const catalog = await this.workspaceArchiveCandidates(session.sessionManager.getCwd());
const root = findArchiveCandidateByIdOrPrefix(catalog, session.sessionId) ?? archiveCandidateFromActiveSession(session, false);
const plan = planSessionArchiveTree(root, catalog);
- const busy = plan.targets.map((target) => target.activeSession).find((target) => target !== undefined && sessionHasActiveWork(target));
+ const busy = plan.targets.map((target) => target.activeSession).find((target) => target !== undefined && this.hasActiveWork(target));
if (busy !== undefined) throw new Error(`Stop current session activity before archiving ${sessionDisplayName(busy)}`);
const archiveInputs = plan.unarchivedTargets.map((target) => archiveInputFromCandidate(target));
@@ -457,6 +486,7 @@ export class PiSessionService {
async abort(sessionId: string): Promise {
const active = this.active.get(sessionId);
if (!active) return;
+ this.clearCompactionPromptQueue(sessionId);
clearSessionQueue(active.runtime.session);
await active.runtime.session.abort();
this.publishActivity(active.runtime.session, "stopped", "idle");
@@ -551,6 +581,7 @@ export class PiSessionService {
this.activities.delete(sessionId);
this.workspaceActivity?.removeSession(sessionId, active.runtime.session.sessionManager.getCwd());
this.clearAuthLossWarningsForSession(sessionId);
+ this.clearCompactionPromptQueue(sessionId);
clearSessionQueue(active.runtime.session);
active.unsubscribe();
try {
@@ -595,18 +626,84 @@ export class PiSessionService {
private bindRuntime(active: ActiveSession): void {
active.unsubscribe();
- for (const [sessionId, candidate] of this.active.entries()) {
- if (candidate === active) this.active.delete(sessionId);
- }
const { session } = active.runtime;
+ for (const [sessionId, candidate] of this.active.entries()) {
+ if (candidate === active) {
+ this.active.delete(sessionId);
+ if (sessionId !== session.sessionId) this.clearCompactionPromptQueue(sessionId);
+ }
+ }
active.unsubscribe = session.subscribe((event) => {
this.events.publish(session.sessionId, toClientEvent(event));
this.publishActivityForEvent(session, event);
+ const eventType = getString(event, "type");
+ if (eventType === "compaction_end") this.scheduleCompactionQueueDrain(session.sessionId);
+ if (eventType === "agent_start" || eventType === "agent_end") this.scheduleCompactionQueueDrain(session.sessionId);
this.publishStatus(session);
});
this.active.set(session.sessionId, active);
}
+ private scheduleCompactionQueueDrain(sessionId: string, delayMs = 0): void {
+ if (!this.compactionPromptQueues.has(sessionId) || this.compactionDrainTimers.has(sessionId)) return;
+ const timer = setTimeout(() => {
+ this.compactionDrainTimers.delete(sessionId);
+ this.drainCompactionPromptQueue(sessionId);
+ }, delayMs);
+ this.compactionDrainTimers.set(sessionId, timer);
+ }
+
+ private drainCompactionPromptQueue(sessionId: string): void {
+ const active = this.active.get(sessionId);
+ if (active === undefined) return;
+ const { session } = active.runtime;
+ if (session.isCompacting) {
+ this.scheduleCompactionQueueDrain(sessionId, 100);
+ return;
+ }
+
+ if (session.isStreaming) {
+ const queued = this.takeCompactionPromptQueue(sessionId);
+ if (queued.length === 0) return;
+ this.publishStatus(session);
+ for (const prompt of queued) void this.submitPrompt(session, prompt.text, prompt.kind);
+ return;
+ }
+
+ const prompt = this.shiftCompactionPrompt(sessionId);
+ if (prompt === undefined) return;
+ this.publishStatus(session);
+ const submitted = this.submitPrompt(session, prompt.text, undefined);
+ void submitted.finally(() => { this.scheduleCompactionQueueDrain(sessionId); });
+ }
+
+ private takeCompactionPromptQueue(sessionId: string): QueuedPrompt[] {
+ const queued = this.compactionPromptQueues.get(sessionId) ?? [];
+ this.compactionPromptQueues.delete(sessionId);
+ return queued;
+ }
+
+ private shiftCompactionPrompt(sessionId: string): QueuedPrompt | undefined {
+ const queue = this.compactionPromptQueues.get(sessionId);
+ const prompt = queue?.shift();
+ if (queue === undefined || queue.length === 0) this.compactionPromptQueues.delete(sessionId);
+ return prompt;
+ }
+
+ private clearCompactionPromptQueue(sessionId: string): void {
+ this.compactionPromptQueues.delete(sessionId);
+ const timer = this.compactionDrainTimers.get(sessionId);
+ if (timer !== undefined) {
+ clearTimeout(timer);
+ this.compactionDrainTimers.delete(sessionId);
+ }
+ }
+
+ private clearCompactionDrainTimers(): void {
+ for (const timer of this.compactionDrainTimers.values()) clearTimeout(timer);
+ this.compactionDrainTimers.clear();
+ }
+
private maybeGenerateSessionName(session: PiAgentSession, firstMessage: string): void {
if (session.sessionName !== undefined || session.messages.length !== 0 || session.isStreaming || session.isCompacting) return;
const model = session.model;
@@ -674,7 +771,7 @@ export class PiSessionService {
for (const active of this.active.values()) {
const { session } = active.runtime;
const activity = this.activities.get(session.sessionId);
- if (!sessionHasActiveWork(session)) {
+ if (!this.hasActiveWork(session)) {
if (activity?.phase === "active") this.publishStatus(session);
continue;
}
@@ -688,10 +785,14 @@ export class PiSessionService {
if (session.isCompacting) return "compacting";
if (session.isBashRunning) return "running bash";
if (session.isStreaming) return "agent running";
- if (session.pendingMessageCount) return "queued";
+ if (this.pendingMessageCount(session) > 0) return "queued";
return "active";
}
+ private hasActiveWork(session: PiAgentSession): boolean {
+ return sessionHasActiveWork(session, this.compactionQueuedMessages(session.sessionId).length);
+ }
+
private publishActivityForEvent(session: PiAgentSession, event: unknown): void {
const eventType = getString(event, "type");
if (eventType === undefined) return;
@@ -716,7 +817,7 @@ export class PiSessionService {
}
if (eventType === "bash_execution_start") { this.publishActivity(session, "running bash", "active"); return; }
if (eventType === "bash_execution_end") { this.publishActivity(session, "bash complete", "idle"); return; }
- if (sessionHasActiveWork(session)) this.publishActivity(session, eventType.replaceAll("_", " "), "active");
+ if (this.hasActiveWork(session)) this.publishActivity(session, eventType.replaceAll("_", " "), "active");
}
private publishActivity(session: PiAgentSession, label: string, phase: "active" | "idle" | "error", detail?: string): void {
@@ -739,7 +840,7 @@ export class PiSessionService {
private clearStaleActiveActivity(session: PiAgentSession): void {
const current = this.activities.get(session.sessionId);
- if (current?.phase !== "active" || sessionHasActiveWork(session)) return;
+ if (current?.phase !== "active" || this.hasActiveWork(session)) return;
const at = new Date().toISOString();
const stored = { phase: "idle" as const, label: "idle", at };
this.activities.set(session.sessionId, stored);
@@ -759,14 +860,26 @@ export class PiSessionService {
isStreaming: session.isStreaming,
isCompacting: session.isCompacting,
isBashRunning: session.isBashRunning,
- pendingMessageCount: session.pendingMessageCount,
- queuedMessages: queuedMessagesFromSession(session),
+ pendingMessageCount: this.pendingMessageCount(session),
+ queuedMessages: queuedMessagesFromSession(session, this.compactionQueuedMessages(session.sessionId)),
messageCount: session.messages.length,
tokens: stats.tokens,
cost: stats.cost,
...(contextUsage === undefined ? {} : { contextUsage }),
};
}
+
+ private pendingMessageCount(session: PiAgentSession): number {
+ return session.pendingMessageCount + this.compactionQueuedMessages(session.sessionId).length;
+ }
+
+ private compactionQueuedMessages(sessionId: string): readonly QueuedPrompt[] {
+ return this.compactionPromptQueues.get(sessionId) ?? [];
+ }
+
+ private hasQueuedMessageText(session: PiAgentSession, text: string): boolean {
+ return queuedMessagesFromSession(session, this.compactionQueuedMessages(session.sessionId)).some((message) => message.text === text);
+ }
}
function modelToClientModel(model: PiAgentSession["model"]): ClientSessionModel {
@@ -872,8 +985,8 @@ function archiveInputFromCandidate(candidate: WorkspaceArchiveCandidate): Archiv
throw new Error(`Session is not available for archiving: ${candidate.id}`);
}
-function sessionHasActiveWork(session: PiAgentSession): boolean {
- return session.isStreaming || session.isCompacting || session.isBashRunning || session.pendingMessageCount > 0;
+function sessionHasActiveWork(session: PiAgentSession, extraQueuedMessageCount = 0): boolean {
+ return session.isStreaming || session.isCompacting || session.isBashRunning || session.pendingMessageCount + extraQueuedMessageCount > 0;
}
function sessionDisplayName(session: PiAgentSession): string {
@@ -938,14 +1051,11 @@ function clearSessionQueue(session: PiAgentSession): void {
session.clearQueue();
}
-function hasQueuedMessageText(session: PiAgentSession, text: string): boolean {
- return queuedMessagesFromSession(session).some((message) => message.text === text);
-}
-
-function queuedMessagesFromSession(session: PiAgentSession): { kind: "steer" | "followUp"; text: string }[] {
+function queuedMessagesFromSession(session: PiAgentSession, extraQueuedMessages: readonly QueuedPrompt[] = []): { kind: "steer" | "followUp"; text: string }[] {
return [
...session.getSteeringMessages().map((text) => ({ kind: "steer" as const, text })),
...session.getFollowUpMessages().map((text) => ({ kind: "followUp" as const, text })),
+ ...extraQueuedMessages,
];
}
diff --git a/src/server/terminalProxyRoutes.ts b/src/server/terminalProxyRoutes.ts
index a0bac1c..7310352 100644
--- a/src/server/terminalProxyRoutes.ts
+++ b/src/server/terminalProxyRoutes.ts
@@ -1,6 +1,6 @@
import type { FastifyInstance, FastifyReply } from "fastify";
import type { ProjectService } from "./projects/projectService.js";
-import { SessionDaemonClient } from "./sessiond/sessionDaemonClient.js";
+import { SessionDaemonClient } from "../sessiond/sessionDaemonClient.js";
import type { SessionProxyDaemon } from "./sessiond/sessionProxyRoutes.js";
import { resolveWorkspaceContext } from "./workspaces/workspaceContext.js";
import type { WorkspaceService } from "./workspaces/workspaceService.js";
diff --git a/src/server/workspaces/fileSuggestions.test.ts b/src/server/workspaces/fileSuggestions.test.ts
new file mode 100644
index 0000000..be02b20
--- /dev/null
+++ b/src/server/workspaces/fileSuggestions.test.ts
@@ -0,0 +1,75 @@
+import { mkdtemp, mkdir, rm, writeFile } from "node:fs/promises";
+import { tmpdir } from "node:os";
+import { join } from "node:path";
+import { afterEach, describe, expect, it } from "vitest";
+import { listFileSuggestions, type FileSuggestionDependencies } from "./fileSuggestions";
+
+const temporaryRoots: string[] = [];
+
+async function tempWorkspace(): Promise {
+ const root = await mkdtemp(join(tmpdir(), "pi-web-files-"));
+ temporaryRoots.push(root);
+ return root;
+}
+
+afterEach(async () => {
+ await Promise.all(temporaryRoots.splice(0).map((root) => rm(root, { recursive: true, force: true })));
+});
+
+describe("file suggestions", () => {
+ it("uses tracked git files for tracked-scope suggestions", async () => {
+ const calls: { file: string; args: string[] }[] = [];
+ const deps: FileSuggestionDependencies = {
+ execFile: (file, args) => {
+ calls.push({ file, args });
+ if (file === "git" && args.join(" ") === "ls-files") return Promise.resolve({ stdout: "src/app.ts\nREADME.md\n" });
+ return Promise.reject(new Error(`unexpected command: ${file} ${args.join(" ")}`));
+ },
+ };
+
+ await expect(listFileSuggestions("/repo", "", { scope: "tracked" }, deps)).resolves.toEqual([
+ { path: "src/", kind: "tracked" },
+ { path: "README.md", kind: "tracked" },
+ { path: "src/app.ts", kind: "tracked" },
+ ]);
+ expect(calls).toEqual([{ file: "git", args: ["ls-files"] }]);
+ });
+
+ it("asks ripgrep for hidden and ignored files in all-file scope", async () => {
+ const calls: { file: string; args: string[] }[] = [];
+ const deps: FileSuggestionDependencies = {
+ execFile: (file, args) => {
+ calls.push({ file, args });
+ return Promise.resolve({ stdout: "node_modules/pkg/index.js\nsrc/app.ts\n" });
+ },
+ };
+
+ await expect(listFileSuggestions("/repo", "pkg", { scope: "all" }, deps)).resolves.toEqual([
+ { path: "node_modules/pkg/", kind: "other" },
+ { path: "node_modules/pkg/index.js", kind: "other" },
+ ]);
+ expect(calls).toEqual([{ file: "rg", args: ["--files", "--hidden", "--no-ignore"] }]);
+ });
+
+ it("falls back to a bounded filesystem scan without directory exclusions when git and rg are unavailable", async () => {
+ const root = await tempWorkspace();
+ await mkdir(join(root, "src"), { recursive: true });
+ await mkdir(join(root, "node_modules", "pkg"), { recursive: true });
+ await writeFile(join(root, "README.md"), "hello");
+ await writeFile(join(root, "src", "app.ts"), "export {};\n");
+ await writeFile(join(root, "node_modules", "pkg", "index.js"), "module.exports = {};\n");
+
+ const deps: FileSuggestionDependencies = {
+ execFile: (file) => Promise.reject(Object.assign(new Error(`spawn ${file} ENOENT`), { code: "ENOENT" })),
+ };
+
+ await expect(listFileSuggestions(root, "", { scope: "all" }, deps)).resolves.toEqual([
+ { path: "node_modules/", kind: "other" },
+ { path: "node_modules/pkg/", kind: "other" },
+ { path: "src/", kind: "other" },
+ { path: "node_modules/pkg/index.js", kind: "other" },
+ { path: "README.md", kind: "other" },
+ { path: "src/app.ts", kind: "other" },
+ ]);
+ });
+});
diff --git a/src/server/workspaces/fileSuggestions.ts b/src/server/workspaces/fileSuggestions.ts
index 09263da..67af66c 100644
--- a/src/server/workspaces/fileSuggestions.ts
+++ b/src/server/workspaces/fileSuggestions.ts
@@ -6,13 +6,33 @@ import { sanitizedGitEnv } from "../git/gitEnv.js";
import type { ClientFileSuggestion } from "../types.js";
const execFileAsync = promisify(execFile);
+const commandMaxBuffer = 1024 * 1024 * 8;
+const maxFilesystemFallbackPaths = 20_000;
-export async function listFileSuggestions(cwd: string, query = "", kind?: ClientFileSuggestion["kind"]): Promise {
- const normalizedQuery = query.replace(/^@/, "").toLowerCase();
- const files = await listGitFiles(cwd).catch(() => listPlainFiles(cwd));
+interface ExecFileOptions {
+ cwd: string;
+ maxBuffer: number;
+ env?: NodeJS.ProcessEnv;
+}
+
+export type FileSuggestionScope = "tracked" | "all";
+
+export interface FileSuggestionOptions {
+ kind?: ClientFileSuggestion["kind"] | undefined;
+ scope?: FileSuggestionScope | undefined;
+}
+
+export interface FileSuggestionDependencies {
+ execFile?: (file: string, args: string[], options: ExecFileOptions) => Promise<{ stdout: string }>;
+}
+
+export async function listFileSuggestions(cwd: string, query = "", options: FileSuggestionOptions = {}, deps: FileSuggestionDependencies = {}): Promise {
+ const normalizedQuery = normalizeFileQuery(query);
+ const exec = deps.execFile ?? execFileAsync;
+ const files = await listFilesForScope(cwd, options.scope, exec);
return files
- .filter((file) => !kind || file.kind === kind)
- .filter((file) => !normalizedQuery || file.path.toLowerCase().includes(normalizedQuery))
+ .filter((file) => options.kind === undefined || file.kind === options.kind)
+ .filter((file) => normalizedQuery === "" || file.path.toLowerCase().includes(normalizedQuery))
.sort((a, b) => Number(!a.path.endsWith("/")) - Number(!b.path.endsWith("/")) || a.path.localeCompare(b.path))
.slice(0, 80);
}
@@ -40,10 +60,20 @@ export async function listPathSuggestions(cwd: string, prefix = ""): Promise {
+async function listFilesForScope(cwd: string, scope: FileSuggestionScope | undefined, exec: NonNullable): Promise {
+ if (scope === "all") return listPlainFiles(cwd, exec, true);
+ if (scope === "tracked") return listTrackedFiles(cwd, exec).catch(() => listPlainFiles(cwd, exec, true));
+ return listGitFiles(cwd, exec).catch(() => listPlainFiles(cwd, exec, false));
+}
+
+async function listTrackedFiles(cwd: string, exec: NonNullable): Promise {
+ return withDirectories(lines(await git(cwd, ["ls-files"], exec)), "tracked");
+}
+
+async function listGitFiles(cwd: string, exec: NonNullable): Promise {
const [tracked, untracked] = await Promise.all([
- git(cwd, ["ls-files"]),
- git(cwd, ["ls-files", "--others", "--exclude-standard"]),
+ git(cwd, ["ls-files"], exec),
+ git(cwd, ["ls-files", "--others", "--exclude-standard"], exec),
]);
return [
...withDirectories(lines(tracked), "tracked"),
@@ -51,16 +81,63 @@ async function listGitFiles(cwd: string): Promise {
];
}
-async function listPlainFiles(cwd: string): Promise {
- const { stdout } = await execFileAsync("rg", ["--files"], { cwd, maxBuffer: 1024 * 1024 * 8 });
- return withDirectories(lines(stdout), "other");
+async function listPlainFiles(cwd: string, exec: NonNullable, includeIgnored: boolean): Promise {
+ try {
+ const args = includeIgnored ? ["--files", "--hidden", "--no-ignore"] : ["--files"];
+ const { stdout } = await exec("rg", args, { cwd, maxBuffer: commandMaxBuffer });
+ return withDirectories(lines(stdout), "other");
+ } catch {
+ return withDirectories(await filesystemFiles(cwd), "other");
+ }
}
-async function git(cwd: string, args: string[]): Promise {
- const { stdout } = await execFileAsync("git", args, { cwd, env: sanitizedGitEnv(), maxBuffer: 1024 * 1024 * 8 });
+async function filesystemFiles(cwd: string): Promise {
+ const paths: string[] = [];
+ await collectFilesystemFiles(cwd, "", paths, false);
+ return paths;
+}
+
+async function collectFilesystemFiles(cwd: string, relativeDirectory: string, paths: string[], optionalDirectory: boolean): Promise {
+ if (paths.length >= maxFilesystemFallbackPaths) return;
+ const absoluteDirectory = relativeDirectory === "" ? cwd : join(cwd, relativeDirectory);
+ let entries;
+ try {
+ entries = await readdir(absoluteDirectory, { withFileTypes: true });
+ } catch (error) {
+ if (optionalDirectory) return;
+ throw error;
+ }
+
+ entries.sort((a, b) => Number(!a.isDirectory()) - Number(!b.isDirectory()) || a.name.localeCompare(b.name));
+ for (const entry of entries) {
+ if (paths.length >= maxFilesystemFallbackPaths) return;
+ const relativePath = relativeDirectory === "" ? entry.name : `${relativeDirectory}/${entry.name}`;
+ if (entry.isDirectory()) {
+ await collectFilesystemFiles(cwd, relativePath, paths, true);
+ continue;
+ }
+ if (entry.isFile() || await isSymlinkedFile(cwd, relativePath, entry.isSymbolicLink())) paths.push(relativePath);
+ }
+}
+
+async function isSymlinkedFile(cwd: string, relativePath: string, symbolicLink: boolean): Promise {
+ if (!symbolicLink) return false;
+ try {
+ return (await stat(join(cwd, relativePath))).isFile();
+ } catch {
+ return false;
+ }
+}
+
+async function git(cwd: string, args: string[], exec: NonNullable): Promise {
+ const { stdout } = await exec("git", args, { cwd, env: sanitizedGitEnv(), maxBuffer: commandMaxBuffer });
return stdout;
}
+function normalizeFileQuery(query: string): string {
+ return query.replace(/^!@/, "").replace(/^@\s?/, "").toLowerCase();
+}
+
function lines(text: string): string[] {
return text.split("\n").map((line) => line.trim()).filter(Boolean);
}
diff --git a/src/server/workspaces/fileTreeService.test.ts b/src/server/workspaces/fileTreeService.test.ts
index 1e213c8..bcb09a5 100644
--- a/src/server/workspaces/fileTreeService.test.ts
+++ b/src/server/workspaces/fileTreeService.test.ts
@@ -17,7 +17,7 @@ afterEach(async () => {
});
describe("listWorkspaceTree", () => {
- it("lists visible entries with directories first, sorted by name", async () => {
+ it("lists entries with directories first, sorted by name", async () => {
const root = await tempWorkspace();
await mkdir(join(root, "z-dir"));
await mkdir(join(root, "a-dir"));
@@ -32,7 +32,9 @@ describe("listWorkspaceTree", () => {
expect(tree.path).toBe("");
expect(tree.truncated).toBe(false);
expect(tree.entries.map((entry) => [entry.name, entry.type])).toEqual([
+ [".git", "directory"],
["a-dir", "directory"],
+ ["node_modules", "directory"],
["z-dir", "directory"],
["a.txt", "file"],
["b.txt", "file"],
diff --git a/src/server/workspaces/fileTreeService.ts b/src/server/workspaces/fileTreeService.ts
index 6e62bde..fcd7ff2 100644
--- a/src/server/workspaces/fileTreeService.ts
+++ b/src/server/workspaces/fileTreeService.ts
@@ -11,11 +11,11 @@ export async function listWorkspaceTree(rootPath: string, path: string | undefin
if (!stat.isDirectory()) throw new Error("Path is not a directory");
const dirents = await readdir(target, { withFileTypes: true });
- const visible = dirents.filter((entry) => entry.name !== ".git" && entry.name !== "node_modules").sort((a, b) => {
+ const sorted = dirents.sort((a, b) => {
if (a.isDirectory() !== b.isDirectory()) return a.isDirectory() ? -1 : 1;
return a.name.localeCompare(b.name);
});
- const selected = visible.slice(0, MAX_ENTRIES);
+ const selected = sorted.slice(0, MAX_ENTRIES);
const entries = await Promise.all(selected.map(async (entry): Promise => {
const absolute = join(target, entry.name);
const childRelative = relativePath === "" ? entry.name : `${relativePath}/${entry.name}`;
@@ -24,5 +24,5 @@ export async function listWorkspaceTree(rootPath: string, path: string | undefin
return { name: entry.name, path: childRelative, type, size: childStat.size, modifiedAt: childStat.mtime.toISOString() };
}));
- return { path: relativePath, entries, scannedAt: new Date().toISOString(), truncated: visible.length > selected.length };
+ return { path: relativePath, entries, scannedAt: new Date().toISOString(), truncated: sorted.length > selected.length };
}
diff --git a/src/server/sessiond/config.ts b/src/sessiond/config.ts
similarity index 85%
rename from src/server/sessiond/config.ts
rename to src/sessiond/config.ts
index a20e9b3..70b9fcf 100644
--- a/src/server/sessiond/config.ts
+++ b/src/sessiond/config.ts
@@ -1,5 +1,5 @@
import { join } from "node:path";
-import { piWebDataDir } from "../../config.js";
+import { piWebDataDir } from "../config.js";
export function sessiondSocketPath(): string {
return process.env["PI_WEB_SESSIOND_SOCKET"] ?? join(piWebDataDir(), "sessiond.sock");
diff --git a/src/server/sessiond/sessionDaemonClient.ts b/src/sessiond/sessionDaemonClient.ts
similarity index 100%
rename from src/server/sessiond/sessionDaemonClient.ts
rename to src/sessiond/sessionDaemonClient.ts
diff --git a/src/shared/apiTypes.ts b/src/shared/apiTypes.ts
index 800fe74..817530a 100644
--- a/src/shared/apiTypes.ts
+++ b/src/shared/apiTypes.ts
@@ -308,19 +308,23 @@ export interface PiWebStatusMessage {
command?: string;
}
-export interface PiWebStatusResponse {
+export interface PiWebVersionResponse {
packageName: string;
generatedAt: string;
components: {
web: PiWebComponentStatus;
sessiond: PiWebComponentStatus;
};
+}
+
+export interface PiWebStatusResponse extends PiWebVersionResponse {
release: PiWebReleaseStatus;
commands: {
- update: string;
- restart: string;
- restartSystemd: string;
- restartDev: string;
+ update?: string;
+ restart?: string;
+ restartWeb?: string;
+ restartSessiond?: string;
+ status?: string;
};
messages: PiWebStatusMessage[];
}
diff --git a/src/shared/piWebStatusParsing.ts b/src/shared/piWebStatusParsing.ts
new file mode 100644
index 0000000..a588dc5
--- /dev/null
+++ b/src/shared/piWebStatusParsing.ts
@@ -0,0 +1,58 @@
+import type { PiWebComponentStatus, PiWebInstallationInfo, PiWebVersionResponse } from "./apiTypes.js";
+
+export function parsePiWebVersionResponse(value: unknown): PiWebVersionResponse | undefined {
+ if (!isRecord(value)) return undefined;
+ const packageName = value["packageName"];
+ const generatedAt = value["generatedAt"];
+ const components = value["components"];
+ if (typeof packageName !== "string" || packageName === "" || typeof generatedAt !== "string" || generatedAt === "" || !isRecord(components)) return undefined;
+ const web = parsePiWebComponentStatus(components["web"]);
+ const sessiond = parsePiWebComponentStatus(components["sessiond"]);
+ if (web === undefined || sessiond === undefined) return undefined;
+ return { packageName, generatedAt, components: { web, sessiond } };
+}
+
+export function parsePiWebComponentStatus(value: unknown): PiWebComponentStatus | undefined {
+ if (!isRecord(value)) return undefined;
+ const component = value["component"];
+ const label = value["label"];
+ const runtimeVersion = value["runtimeVersion"];
+ const installedVersion = value["installedVersion"];
+ const stale = value["stale"];
+ const available = value["available"];
+ const error = value["error"];
+ const installation = parsePiWebInstallationInfo(value["installation"]);
+ if (component !== "web" && component !== "sessiond") return undefined;
+ if (typeof label !== "string" || label === "" || typeof stale !== "boolean" || typeof available !== "boolean") return undefined;
+ return {
+ component,
+ label,
+ ...(typeof runtimeVersion === "string" ? { runtimeVersion } : {}),
+ ...(typeof installedVersion === "string" ? { installedVersion } : {}),
+ stale,
+ available,
+ ...(installation === undefined ? {} : { installation }),
+ ...(typeof error === "string" ? { error } : {}),
+ };
+}
+
+export function parsePiWebInstallationInfo(value: unknown): PiWebInstallationInfo | undefined {
+ if (!isRecord(value)) return undefined;
+ const kind = value["kind"];
+ const path = value["path"];
+ const source = value["source"];
+ const scope = value["scope"];
+ const npmRoot = value["npmRoot"];
+ if (kind !== "pi-package" && kind !== "npm-global" && kind !== "local" && kind !== "unknown") return undefined;
+ return {
+ kind,
+ ...(typeof path === "string" ? { path } : {}),
+ ...(typeof source === "string" ? { source } : {}),
+ ...(scope === "user" || scope === "project" ? { scope } : {}),
+ ...(typeof npmRoot === "string" ? { npmRoot } : {}),
+ };
+}
+
+function isRecord(value: unknown): value is Record {
+ return typeof value === "object" && value !== null && !Array.isArray(value);
+}
-
+ ${this.collapsed ? null : html`
+
- `)}
+ `}
`;
}
diff --git a/src/client/src/components/PromptEditor.ts b/src/client/src/components/PromptEditor.ts
index e820b23..ea7b40f 100644
--- a/src/client/src/components/PromptEditor.ts
+++ b/src/client/src/components/PromptEditor.ts
@@ -115,7 +115,7 @@ export class PromptEditor extends LitElement {
syntaxHighlighting(defaultHighlightStyle, { fallback: true }),
EditorView.lineWrapping,
EditorView.contentAttributes.of((view) => inputAssistanceContentAttributes(view.state.sliceDoc(0, view.state.selection.main.head))),
- placeholder("Message pi... Use / for commands, @ for files"),
+ placeholder("Message pi... Use / for commands, @ for tracked files, @ space for all files"),
this.editableCompartment.of(EditorView.editable.of(!this.disabled)),
this.readOnlyCompartment.of(EditorState.readOnly.of(this.disabled)),
EditorView.updateListener.of((update) => {
@@ -188,12 +188,12 @@ export class PromptEditor extends LitElement {
...(command.description === undefined ? {} : { description: command.description }),
}));
} else if (trigger.kind === "file" && this.cwd !== undefined && this.cwd !== "") {
- const files = await api.files(this.cwd, trigger.query, trigger.fileKind, trigger.fileMode, this.machineId).catch(emptyFileSuggestions);
+ const files = await api.files(this.cwd, trigger.query, { scope: trigger.fileScope, machineId: this.machineId }).catch(emptyFileSuggestions);
if (version !== this.requestVersion) return;
this.completions = files
.slice(0, 12)
.map((file) => {
- const insertText = fileInsertText(file.path, trigger.fileMode === "path", trigger.quoted === true);
+ const insertText = fileInsertText(file.path, trigger.quoted === true, file.path.endsWith("/") ? trigger.allPrefix : undefined);
return {
kind: "file",
replaceFrom: trigger.from,
@@ -206,7 +206,7 @@ export class PromptEditor extends LitElement {
}
}
- private currentTrigger(): { kind: "command" | "file"; query: string; from: number; to: number; fileKind?: FileSuggestion["kind"]; fileMode?: "file" | "path"; quoted?: boolean } | undefined {
+ private currentTrigger(): { kind: "command" | "file"; query: string; from: number; to: number; fileScope?: "tracked" | "all" | undefined; allPrefix?: "@ " | "!@" | undefined; quoted?: boolean } | undefined {
const cursor = this.editor?.state.selection.main.head ?? this.draft.length;
const beforeCursor = this.draft.slice(0, cursor);
const quotedTrigger = this.currentQuotedTrigger(beforeCursor, cursor);
@@ -215,18 +215,20 @@ export class PromptEditor extends LitElement {
const tokenStart = Math.max(beforeCursor.lastIndexOf(" "), beforeCursor.lastIndexOf("\n")) + 1;
const token = beforeCursor.slice(tokenStart);
const beforeToken = beforeCursor.slice(0, tokenStart);
- if (beforeToken.endsWith("@ ")) return { kind: "file", query: token, from: tokenStart, to: cursor, fileMode: "path" };
+ if (beforeToken.endsWith("@ ")) return { kind: "file", query: token, from: tokenStart - 2, to: cursor, fileScope: "all", allPrefix: "@ " };
if (token.startsWith("/") && tokenStart === 0) return { kind: "command", query: token.slice(1), from: tokenStart, to: cursor };
- if (token.startsWith("@")) return { kind: "file", query: token.slice(1), from: tokenStart, to: cursor };
+ if (token.startsWith("!@")) return { kind: "file", query: token.slice(2), from: tokenStart, to: cursor, fileScope: "all", allPrefix: "!@" };
+ if (token.startsWith("@")) return { kind: "file", query: token.slice(1), from: tokenStart, to: cursor, fileScope: "tracked" };
return undefined;
}
- private currentQuotedTrigger(beforeCursor: string, cursor: number): { kind: "file"; query: string; from: number; to: number; fileMode?: "file" | "path"; quoted: true } | undefined {
+ private currentQuotedTrigger(beforeCursor: string, cursor: number): { kind: "file"; query: string; from: number; to: number; fileScope?: "tracked" | "all" | undefined; allPrefix?: "@ " | "!@" | undefined; quoted: true } | undefined {
const quoteStart = beforeCursor.lastIndexOf("\"");
if (quoteStart === -1) return undefined;
const prefix = beforeCursor.slice(0, quoteStart);
- if (prefix.endsWith("@")) return { kind: "file", query: beforeCursor.slice(quoteStart + 1), from: prefix.length - 1, to: cursor, quoted: true };
- if (prefix.endsWith("@ ")) return { kind: "file", query: beforeCursor.slice(quoteStart + 1), from: quoteStart, to: cursor, fileMode: "path", quoted: true };
+ if (prefix.endsWith("!@")) return { kind: "file", query: beforeCursor.slice(quoteStart + 1), from: prefix.length - 2, to: cursor, fileScope: "all", allPrefix: "!@", quoted: true };
+ if (prefix.endsWith("@")) return { kind: "file", query: beforeCursor.slice(quoteStart + 1), from: prefix.length - 1, to: cursor, fileScope: "tracked", quoted: true };
+ if (prefix.endsWith("@ ")) return { kind: "file", query: beforeCursor.slice(quoteStart + 1), from: prefix.length - 2, to: cursor, fileScope: "all", allPrefix: "@ ", quoted: true };
return undefined;
}
@@ -299,8 +301,8 @@ function draftStorageKey(machineId: unknown, sessionId: unknown): string | undef
return machineSessionKey(machineId, sessionId);
}
-function fileInsertText(path: string, pathMode: boolean, quoted: boolean): string {
- const prefix = pathMode ? "" : "@";
+function fileInsertText(path: string, quoted: boolean, allPrefix?: "@ " | "!@"): string {
+ const prefix = allPrefix ?? "@";
if (!quoted && !path.includes(" ")) return `${prefix}${path}`;
return `${prefix}"${path}"`;
}
diff --git a/src/client/src/components/SessionList.ts b/src/client/src/components/SessionList.ts
index c60b0bb..0793781 100644
--- a/src/client/src/components/SessionList.ts
+++ b/src/client/src/components/SessionList.ts
@@ -76,11 +76,15 @@ export class SessionList extends LitElement {
return html`
+ ${this.projects.map((project) => html`
+
+ `)}
{ activateSelectableRow(event, () => this.onSelect?.(project)); }}
+ @keydown=${(event: KeyboardEvent) => { activateSelectableRowFromKeyboard(event, () => this.onSelect?.(project)); }}
+ >
+
+
+ ${project.name}${this.renderActivity(project)}${project.path}
- ` : null}
-
+
+ ${this.openMenuProjectId === project.id ? html`
+
+
+
+
+ ` : null}
+
+ ${activeRows.map((row) => this.renderSession(row, descendantCounts.get(row.session.id) ?? 0))}
+ ${archivedRows.length > 0 ? html`
+
+ ${this.archivedExpanded ? archivedRows.map((row) => this.renderSession(row, descendantCounts.get(row.session.id) ?? 0)) : null}
+ ` : null}
+
+ `}
+ ${this.renderSoftKeysToggle()}
${this.terminals.map((terminal) => html`
${this.error === undefined ? null : html`${this.error}
`} ${this.renderCommandRunNotice()} + ${this.shouldShowSoftKeys() ? this.renderSoftKeys() : null} ${this.loading ? html`Loading terminals…
` : null}
+ ${TERMINAL_SOFT_KEYS.map((key) => html`
+
+ `)}
+
+ `;
+ }
+
+ static override styles = css`
+ :host { flex: 0 0 auto; display: block; }
+ .terminal-soft-keys { display: flex; gap: 6px; align-items: center; padding: 6px; border-bottom: 1px solid var(--pi-border-muted); background: var(--pi-bg); overflow-x: auto; overscroll-behavior-x: contain; scrollbar-width: none; touch-action: pan-x; }
+ .terminal-soft-keys::-webkit-scrollbar { display: none; }
+ button { display: inline-flex; align-items: center; gap: 6px; flex: 0 0 auto; max-width: none; min-height: 34px; border: 1px solid var(--pi-border); border-radius: 7px; background: var(--pi-surface); color: var(--pi-text); padding: 6px 9px; font: 12px ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; cursor: pointer; touch-action: pan-x; -webkit-touch-callout: none; user-select: none; }
+ button:disabled { opacity: .5; cursor: not-allowed; }
+ `;
+}
+
+interface SoftKeyPointerStart {
+ pointerId: number;
+ key: TerminalSoftKeyDefinition;
+ clientX: number;
+ clientY: number;
+}
+
+function pointerMovedBeyondTap(start: SoftKeyPointerStart, event: PointerEvent): boolean {
+ return Math.hypot(event.clientX - start.clientX, event.clientY - start.clientY) > SOFT_KEY_TAP_MOVE_THRESHOLD_PX;
+}
diff --git a/src/client/src/components/WorkspaceList.ts b/src/client/src/components/WorkspaceList.ts
index 3874756..b130dbb 100644
--- a/src/client/src/components/WorkspaceList.ts
+++ b/src/client/src/components/WorkspaceList.ts
@@ -49,24 +49,28 @@ export class WorkspaceList extends LitElement {
return html`
${this.renderHeading()}
- ${this.collapsed ? null : this.workspaces.map((workspace) => { - const label = workspacePrimaryLabel(workspace); - const items = this.workspaceLabelItems(workspace); - return html` - { activateSelectableRow(event, () => this.onSelect?.(workspace)); }}
- @keydown=${(event: KeyboardEvent) => { this.handleWorkspaceKeydown(event, workspace); }}
- >
-
- `;
- })}
+ ${this.collapsed ? null : html`
+
- ${this.renderWorkspaceMain(label, items, workspace)}
-
- ${this.renderWorkspaceMenu(label, items, workspace)}
-
+ ${this.workspaces.map((workspace) => {
+ const label = workspacePrimaryLabel(workspace);
+ const items = this.workspaceLabelItems(workspace);
+ return html`
+
+ `}
{ activateSelectableRow(event, () => this.onSelect?.(workspace)); }}
+ @keydown=${(event: KeyboardEvent) => { this.handleWorkspaceKeydown(event, workspace); }}
+ >
+
+ `;
+ })}
+
+ ${this.renderWorkspaceMain(label, items, workspace)}
+
+ ${this.renderWorkspaceMenu(label, items, workspace)}
+
+
+ `;
+ }
+
+ private frameClass(): string {
+ return `mobile-tabs-frame${this.canScrollLeft ? " can-scroll-left" : ""}${this.canScrollRight ? " can-scroll-right" : ""}`;
+ }
+
+ private tabClass(tab: AppMobileMainTab): string {
+ return [
+ ...(tab.className === undefined ? [] : [tab.className]),
+ ...(this.selectedView === tab.id ? ["selected"] : []),
+ ].join(" ");
+ }
+
+ private observeMobileTabs(): void {
+ const mobileTabs = this.mobileTabsElement();
+ if (this.observedMobileTabs === mobileTabs) return;
+ this.mobileTabsResizeObserver?.disconnect();
+ this.observedMobileTabs = mobileTabs;
+ this.mobileTabsResizeObserver = undefined;
+ if (mobileTabs === undefined || typeof ResizeObserver === "undefined") return;
+ this.mobileTabsResizeObserver = new ResizeObserver(() => {
+ this.updateScrollState();
+ });
+ this.mobileTabsResizeObserver.observe(mobileTabs);
+ }
+
+ private updateScrollState(): void {
+ const mobileTabs = this.mobileTabsElement();
+ const maxScrollLeft = mobileTabs === undefined ? 0 : Math.max(0, mobileTabs.scrollWidth - mobileTabs.clientWidth);
+ const canScrollLeft = mobileTabs !== undefined && mobileTabs.scrollLeft > 1;
+ const canScrollRight = mobileTabs !== undefined && maxScrollLeft - mobileTabs.scrollLeft > 1;
+ if (this.canScrollLeft !== canScrollLeft) this.canScrollLeft = canScrollLeft;
+ if (this.canScrollRight !== canScrollRight) this.canScrollRight = canScrollRight;
+ }
+
+ private mobileTabsElement(): HTMLElement | undefined {
+ const mobileTabs = this.mobileTabs;
+ return mobileTabs instanceof HTMLElement ? mobileTabs : undefined;
+ }
+
+ private readonly onMobileTabsScroll = () => {
+ this.updateScrollState();
+ };
+
+ static override styles = css`
+ :host { flex: 0 0 auto; min-width: 0; }
+ .mobile-tabs-frame { position: relative; display: flex; flex: 0 0 auto; min-width: 0; border-bottom: 1px solid var(--pi-border); background: var(--pi-bg); }
+ .mobile-tabs-frame::before, .mobile-tabs-frame::after { content: ""; position: absolute; top: 0; bottom: 0; z-index: 2; width: 20px; opacity: 0; pointer-events: none; transition: opacity .15s ease; }
+ .mobile-tabs-frame::before { left: 0; background: linear-gradient(90deg, color-mix(in srgb, var(--pi-shadow-strong) 55%, transparent) 0%, transparent 100%); }
+ .mobile-tabs-frame::after { right: 0; background: linear-gradient(270deg, color-mix(in srgb, var(--pi-shadow-strong) 55%, transparent) 0%, transparent 100%); }
+ .mobile-tabs-frame.can-scroll-left::before, .mobile-tabs-frame.can-scroll-right::after { opacity: 1; }
+ .mobile-tabs { flex: 1 1 auto; min-width: 0; display: flex; align-items: center; gap: 6px; padding: 8px; overflow-x: auto; overflow-y: hidden; overscroll-behavior-x: contain; scrollbar-width: thin; }
+ .mobile-tabs button { flex: 0 0 auto; white-space: nowrap; }
+ .navigation-tab { display: none; }
+ .mobile-tabs button.selected { border-color: var(--pi-accent); background: var(--pi-selection-bg); }
+ .tab-badge { display: inline-block; min-width: 14px; margin-left: 4px; border: 1px solid var(--pi-success-border); border-radius: 999px; background: var(--pi-success-surface); color: var(--pi-success); padding: 0 5px; font-size: 11px; line-height: 16px; text-align: center; }
+ button { border: 1px solid var(--pi-border); border-radius: 8px; background: var(--pi-surface); color: var(--pi-text); padding: 7px 9px; cursor: pointer; }
+ @media (max-width: 760px) {
+ .navigation-tab { display: block; }
+ }
+ `;
+}
diff --git a/src/client/src/components/appShell/AppNavigationPanel.ts b/src/client/src/components/appShell/AppNavigationPanel.ts
new file mode 100644
index 0000000..853bb74
--- /dev/null
+++ b/src/client/src/components/appShell/AppNavigationPanel.ts
@@ -0,0 +1,132 @@
+import { LitElement, css, html } from "lit";
+import { customElement, property } from "lit/decorators.js";
+import type { Machine, MachineHealth, Project, SessionActivity, SessionInfo, SessionStatus, Workspace, WorkspaceActivity } from "../../api";
+import type { WorkspaceLabelItem } from "../../plugins/types";
+import "../MachineList";
+import "../ProjectList";
+import "../WorkspaceList";
+import "../SessionList";
+
+@customElement("app-navigation-panel")
+export class AppNavigationPanel extends LitElement {
+ @property({ attribute: false }) machines: Machine[] = [];
+ @property({ attribute: false }) selectedMachine?: Machine;
+ @property({ attribute: false }) machineStatuses: Record
+ ${this.tabs.map((tab) => html`
+
+ `)}
+
+
+ ${this.refreshControl}
+
+
+ { event.stopPropagation(); }}>
+
+
+
+ `;
+ }
+
+ private renderRefreshIcon() {
+ return html`
+
+ `;
+ }
+
+ private readonly onRefreshClick = (event: MouseEvent): void => {
+ event.stopPropagation();
+ if (this.suppressNextClick) {
+ this.suppressNextClick = false;
+ return;
+ }
+ this.refresh();
+ };
+
+ private readonly onRefreshPointerDown = (event: PointerEvent): void => {
+ if (!event.isPrimary || event.button !== 0) return;
+ const target = event.currentTarget;
+ if (!(target instanceof HTMLElement)) return;
+ this.clearLongPressTimer();
+ this.suppressNextClick = false;
+ this.longPressTimer = window.setTimeout(() => {
+ this.longPressTimer = undefined;
+ this.suppressNextClick = true;
+ this.openMenu(target);
+ }, REFRESH_LONG_PRESS_MS);
+ };
+
+ private readonly onRefreshContextMenu = (event: MouseEvent): void => {
+ event.preventDefault();
+ event.stopPropagation();
+ this.clearLongPressTimer();
+ this.suppressNextClick = true;
+ this.openMenu(event.currentTarget);
+ };
+
+ private readonly onDocumentClick = (event: MouseEvent): void => {
+ if (event.composedPath().includes(this)) return;
+ this.closeMenu();
+ };
+
+ private readonly onDocumentKeyDown = (event: KeyboardEvent): void => {
+ if (event.key !== "Escape" || !this.menuOpen) return;
+ event.preventDefault();
+ event.stopPropagation();
+ this.closeMenu();
+ };
+
+ private openMenu(target: EventTarget | null): void {
+ this.menuStyle = actionMenuPanelStyle(target, { constrainTo: "viewport" });
+ this.menuOpen = true;
+ }
+
+ private closeMenu(): void {
+ this.menuOpen = false;
+ this.suppressNextClick = false;
+ }
+
+ private refresh(): void {
+ this.closeMenu();
+ void this.onRefresh?.();
+ }
+
+ private reload(): void {
+ this.closeMenu();
+ this.onReload?.();
+ }
+
+ private clearLongPressTimer(): void {
+ if (this.longPressTimer === undefined) return;
+ window.clearTimeout(this.longPressTimer);
+ this.longPressTimer = undefined;
+ }
+
+ static override styles = css`
+ :host { position: relative; z-index: 1; display: flex; align-items: center; pointer-events: auto; -webkit-touch-callout: none; -webkit-user-select: none; user-select: none; }
+ :host, :host * { -webkit-user-select: none; user-select: none; }
+ .app-refresh-button { box-sizing: border-box; width: 36px; height: 36px; display: grid; place-items: center; border: 1px solid var(--pi-border); border-radius: 999px; background: var(--pi-surface); color: var(--pi-text); padding: 0; line-height: 1; cursor: pointer; touch-action: manipulation; -webkit-touch-callout: none; }
+ .app-refresh-icon { width: 18px; height: 18px; fill: none; stroke: currentColor; stroke-width: 2; stroke-linecap: round; stroke-linejoin: round; pointer-events: none; }
+ .app-refresh-button.refreshing .app-refresh-icon { animation: app-refresh-spin .8s linear infinite; }
+ .app-refresh-menu { position: fixed; z-index: 10000; box-sizing: border-box; min-width: min(170px, calc(100vw - 16px)); overflow: auto; padding: 4px; border: 1px solid var(--pi-border); border-radius: 8px; background: var(--pi-surface); box-shadow: 0 8px 24px var(--pi-shadow); overflow-wrap: anywhere; }
+ .app-refresh-menu button { display: block; width: 100%; border: 0; border-radius: 8px; background: transparent; color: var(--pi-text); padding: 7px 9px; text-align: left; white-space: normal; overflow-wrap: anywhere; cursor: pointer; }
+ .app-refresh-menu button:hover, .app-refresh-menu button:focus { background: var(--pi-selection-bg); }
+ @keyframes app-refresh-spin { to { transform: rotate(360deg); } }
+ `;
+}
diff --git a/src/client/src/components/shared.ts b/src/client/src/components/shared.ts
index 41e06be..462c469 100644
--- a/src/client/src/components/shared.ts
+++ b/src/client/src/components/shared.ts
@@ -50,14 +50,20 @@ export interface CompletionItem {
}
export const appStyles = css`
- :host { display: block; height: 100dvh; box-sizing: border-box; padding: env(safe-area-inset-top) env(safe-area-inset-right) env(safe-area-inset-bottom) env(safe-area-inset-left); color: var(--pi-text); background: var(--pi-bg); font: 14px system-ui, sans-serif; }
- .shell { display: grid; grid-template-columns: 340px minmax(420px, 1fr) minmax(360px, 42vw); height: 100%; min-height: 0; }
- aside { display: flex; flex-direction: column; min-height: 0; border-right: 1px solid var(--pi-border); overflow: hidden; }
+ /* Mobile browsers already subtract browser controls from 100dvh; reserve bottom safe area only in standalone PWA modes. */
+ :host { --pi-app-safe-area-bottom: 0px; position: fixed; top: 0; right: 0; left: 0; display: block; height: 100dvh; box-sizing: border-box; overflow: hidden; padding: env(safe-area-inset-top) env(safe-area-inset-right) var(--pi-app-safe-area-bottom) env(safe-area-inset-left); color: var(--pi-text); background: var(--pi-bg); font: 14px system-ui, sans-serif; }
+ :host([pwa-display-mode]) { --pi-app-safe-area-bottom: env(safe-area-inset-bottom); }
+ @media (display-mode: standalone), (display-mode: fullscreen), (display-mode: minimal-ui) {
+ :host { --pi-app-safe-area-bottom: env(safe-area-inset-bottom); }
+ }
+ .shell { --navigation-panel-width: 340px; --workspace-panel-width: minmax(360px, 42vw); display: grid; grid-template-columns: var(--navigation-panel-width) 1px minmax(420px, 1fr) 1px var(--workspace-panel-width); height: 100%; min-height: 0; }
+ aside { grid-column: 1; display: flex; flex-direction: column; min-height: 0; overflow: hidden; }
+ aside app-navigation-panel { flex: 1 1 auto; min-height: 0; }
header { flex: 0 0 auto; display: flex; align-items: center; justify-content: space-between; gap: 8px; padding: 12px; border-bottom: 1px solid var(--pi-border); }
.header-actions { display: flex; align-items: center; gap: 8px; }
- project-list, workspace-list { flex: 0 0 auto; max-height: 26%; overflow: auto; border-bottom: 1px solid var(--pi-border-muted); }
- session-list { flex: 1 1 auto; min-height: 0; overflow: auto; }
- main { display: flex; flex-direction: column; min-width: 0; min-height: 0; }
+ project-list, workspace-list { flex: 0 0 auto; max-height: 26%; min-height: 0; overflow: hidden; border-bottom: 1px solid var(--pi-border-muted); }
+ session-list { flex: 1 1 auto; min-height: 0; overflow: hidden; }
+ main { grid-column: 3; display: flex; flex-direction: column; min-width: 0; min-height: 0; }
.context-bar { position: relative; flex: 0 0 auto; min-width: 0; display: none; align-items: center; gap: 0; padding: 6px 0; border-bottom: 1px solid var(--pi-border-muted); background: var(--pi-bg); }
.context-bar::before, .context-bar::after { content: ""; position: absolute; top: 0; bottom: 0; z-index: 2; width: 20px; opacity: 0; pointer-events: none; transition: opacity .15s ease; }
.context-bar::before { left: 0; background: linear-gradient(90deg, color-mix(in srgb, var(--pi-shadow-strong) 55%, transparent) 0%, transparent 100%); }
@@ -83,6 +89,7 @@ export const appStyles = css`
.context-chip.empty { border-style: dashed; color: var(--pi-muted); }
.context-kind { display: none; }
.context-value { min-width: 0; overflow: visible; text-overflow: clip; white-space: nowrap; }
+ app-mobile-main-tabs { display: none; }
.mobile-tabs-frame { position: relative; display: none; flex: 0 0 auto; min-width: 0; border-bottom: 1px solid var(--pi-border); background: var(--pi-bg); }
.mobile-tabs-frame::before, .mobile-tabs-frame::after { content: ""; position: absolute; top: 0; bottom: 0; z-index: 2; width: 20px; opacity: 0; pointer-events: none; transition: opacity .15s ease; }
.mobile-tabs-frame::before { left: 0; background: linear-gradient(90deg, color-mix(in srgb, var(--pi-shadow-strong) 55%, transparent) 0%, transparent 100%); }
@@ -93,31 +100,51 @@ export const appStyles = css`
.mobile-navigation-tab, .mobile-navigation-panel { display: none; }
.mobile-tabs button.selected { border-color: var(--pi-accent); background: var(--pi-selection-bg); }
.tab-badge { display: inline-block; min-width: 14px; margin-left: 4px; border: 1px solid var(--pi-success-border); border-radius: 999px; background: var(--pi-success-surface); color: var(--pi-success); padding: 0 5px; font-size: 11px; line-height: 16px; text-align: center; }
- workspace-panel { min-width: 0; min-height: 0; border-left: 1px solid var(--pi-border); overflow: hidden; }
+ .navigation-panel-edge, .workspace-panel-edge { min-width: 0; min-height: 0; display: flex; align-items: center; justify-content: center; overflow: visible; background: var(--pi-border-muted); z-index: 2; }
+ .navigation-panel-edge { grid-column: 2; }
+ .workspace-panel-edge { grid-column: 4; }
+ .navigation-panel-edge-button, .workspace-panel-edge-button { position: relative; z-index: 1; box-sizing: border-box; display: grid; place-items: center; width: 18px; height: 48px; padding: 0; border: 1px solid var(--pi-border-muted); border-radius: 999px; background: var(--pi-bg); color: var(--pi-muted); opacity: .75; cursor: pointer; }
+ .navigation-panel-edge-button:hover, .navigation-panel-edge-button:focus-visible, .workspace-panel-edge-button:hover, .workspace-panel-edge-button:focus-visible { color: var(--pi-text); background: var(--pi-surface-hover); opacity: 1; }
+ .shell.navigation-panel-collapsed .navigation-panel-edge-button { transform: translateX(calc(50% - .5px)); }
+ .shell.workspace-panel-collapsed .workspace-panel-edge-button { transform: translateX(calc(-50% + .5px)); }
+ .navigation-panel-edge-icon, .workspace-panel-edge-icon { width: 12px; height: 12px; fill: none; stroke: currentColor; stroke-width: 2.2; stroke-linecap: round; stroke-linejoin: round; pointer-events: none; }
+ workspace-panel { grid-column: 5; min-width: 0; min-height: 0; overflow: hidden; }
+ @media (min-width: 1181px) {
+ .shell.navigation-panel-collapsed { --navigation-panel-width: 0px; }
+ .shell.navigation-panel-collapsed > aside { display: none; }
+ .shell.workspace-panel-collapsed { --workspace-panel-width: 0px; }
+ .shell.workspace-panel-collapsed > workspace-panel { display: none; }
+ }
@media (max-width: 1180px) {
- .shell { grid-template-columns: 340px minmax(0, 1fr); grid-template-rows: auto minmax(0, 1fr); }
+ .shell { grid-template-columns: var(--navigation-panel-width) 1px minmax(0, 1fr); grid-template-rows: auto minmax(0, 1fr); }
+ .shell.navigation-panel-collapsed { --navigation-panel-width: 0px; }
+ .shell.navigation-panel-collapsed > aside { display: none; }
aside { grid-row: 1 / 3; }
- main { grid-column: 2; grid-row: 1 / 3; }
+ .navigation-panel-edge { grid-row: 1 / 3; }
+ main { grid-column: 3; grid-row: 1 / 3; }
+ app-mobile-main-tabs { display: block; flex: 0 0 auto; min-width: 0; }
.mobile-tabs-frame { display: flex; }
.shell.workspace-view main { grid-row: 1; min-height: auto; }
- .shell.workspace-view > workspace-panel { grid-column: 2; grid-row: 2; display: flex; border-left: 0; }
+ .shell.workspace-view > workspace-panel { grid-column: 3; grid-row: 2; display: flex; border-left: 0; }
.shell:not(.workspace-view) > workspace-panel { display: none; }
+ .workspace-panel-edge { display: none; }
main.workspace-view chat-view, main.workspace-view prompt-editor, main.workspace-view status-bar,
main.workspace-view .empty { display: none; }
main.workspace-view { overflow: hidden; }
}
@media (max-width: 760px) {
.shell { grid-template-columns: minmax(0, 1fr); }
- aside { display: none; }
+ aside, .navigation-panel-edge { display: none; }
main, .shell.workspace-view > workspace-panel { grid-column: 1; }
.context-bar { display: flex; }
.mobile-navigation-tab { display: block; }
main.navigation-view chat-view, main.navigation-view prompt-editor, main.navigation-view status-bar,
main.navigation-view .empty { display: none; }
main.navigation-view .mobile-navigation-panel { flex: 1 1 auto; min-height: 0; display: flex; flex-direction: column; overflow: hidden; }
+ main.navigation-view .mobile-navigation-panel app-navigation-panel { flex: 1 1 auto; min-height: 0; }
main.navigation-view .mobile-navigation-panel project-list,
main.navigation-view .mobile-navigation-panel workspace-list,
- main.navigation-view .mobile-navigation-panel session-list { flex: 1 1 auto; max-height: none; min-height: 0; overflow: auto; }
+ main.navigation-view .mobile-navigation-panel session-list { flex: 1 1 auto; max-height: none; min-height: 0; overflow: hidden; }
main.navigation-view .mobile-navigation-panel project-list[collapsed],
main.navigation-view .mobile-navigation-panel workspace-list[collapsed],
main.navigation-view .mobile-navigation-panel session-list[collapsed] { flex: 0 0 auto; min-height: auto; overflow: hidden; }
@@ -140,7 +167,7 @@ export const workspacePanelStyles = css`
.workspace-header-scroll-frame::after { right: 0; background: linear-gradient(270deg, color-mix(in srgb, var(--pi-shadow-strong) 55%, transparent) 0%, transparent 100%); }
.workspace-header-scroll-frame.can-scroll-left::before, .workspace-header-scroll-frame.can-scroll-right::after { opacity: 1; }
.workspace-header-strip { display: flex; justify-content: space-between; align-items: center; gap: 8px; min-width: 0; padding: 8px; overflow-x: auto; overflow-y: hidden; overscroll-behavior-x: contain; scrollbar-width: thin; }
- .tabs { flex: 0 0 auto; display: flex; gap: 6px; }
+ .tabs { flex: 0 0 auto; display: flex; gap: 6px; align-items: center; }
.tabs button { flex: 0 0 auto; white-space: nowrap; }
button { display: inline-flex; align-items: center; gap: 5px; border: 1px solid var(--pi-border); border-radius: 7px; background: var(--pi-surface); color: var(--pi-text); padding: 5px 7px; cursor: pointer; }
button.selected { border-color: var(--pi-accent); background: var(--pi-selection-bg); }
@@ -183,10 +210,11 @@ export const workspacePanelStyles = css`
`;
export const listStyles = css`
- :host { display: block; color: var(--pi-text); font: 14px system-ui, sans-serif; }
+ :host { display: flex; flex-direction: column; min-height: 0; overflow: hidden; color: var(--pi-text); font: 14px system-ui, sans-serif; }
:host([collapsed]) { flex: 0 0 auto; min-height: auto; overflow: hidden; }
- section { padding: 10px; }
- h2 { display: flex; justify-content: space-between; align-items: center; gap: 8px; margin: 0 0 8px; color: var(--pi-muted); font-size: 12px; text-transform: uppercase; }
+ section { box-sizing: border-box; flex: 1 1 auto; min-height: 0; display: flex; flex-direction: column; padding: 10px; }
+ h2 { flex: 0 0 auto; display: flex; justify-content: space-between; align-items: center; gap: 8px; margin: 0 0 8px; color: var(--pi-muted); font-size: 12px; text-transform: uppercase; }
+ .list-body { flex: 1 1 auto; min-height: 0; overflow: auto; }
button { border: 1px solid var(--pi-border); border-radius: 8px; background: var(--pi-surface); color: var(--pi-text); padding: 7px 9px; cursor: pointer; }
section > button { display: block; width: 100%; text-align: left; margin: 6px 0; }
.subheading { margin-top: 14px; }
@@ -242,11 +270,11 @@ export const listStyles = css`
`;
export const chatStyles = css`
- :host { display: flex; flex-direction: column; min-height: 0; overflow: hidden; color: var(--pi-text); font: 14px system-ui, sans-serif; }
+ :host { position: relative; z-index: 0; display: flex; flex-direction: column; min-height: 0; overflow: hidden; color: var(--pi-text); font: 14px system-ui, sans-serif; }
.chat-wrap { position: relative; flex: 1 1 auto; min-height: 0; overflow: hidden; }
.chat { height: 100%; min-height: 0; overflow: auto; overflow-anchor: none; padding: 26px 16px 64px; box-sizing: border-box; }
.scroll-marker { display: block; height: 0; overflow: hidden; pointer-events: none; }
- .activity-dock { position: absolute; left: 16px; right: 16px; bottom: 12px; z-index: 3; display: flex; align-items: center; gap: 8px; min-width: 0; box-sizing: border-box; border: 1px solid var(--pi-border); border-radius: 999px; background: var(--pi-bg-overlay); color: var(--pi-muted); padding: 8px 12px; font-size: 13px; pointer-events: none; box-shadow: 0 8px 28px var(--pi-shadow); backdrop-filter: blur(6px); }
+ .activity-dock { position: absolute; left: 16px; right: 16px; bottom: 12px; z-index: 20; display: flex; align-items: center; gap: 8px; min-width: 0; box-sizing: border-box; border: 1px solid var(--pi-border); border-radius: 999px; background: var(--pi-bg-overlay); color: var(--pi-muted); padding: 8px 12px; font-size: 13px; pointer-events: none; box-shadow: 0 8px 28px var(--pi-shadow); backdrop-filter: blur(6px); }
.activity-dock.active { border-color: var(--pi-success-border); color: var(--pi-success); background: var(--pi-success-bg-overlay); }
.activity-text { min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.dot { width: 8px; height: 8px; border-radius: 50%; background: currentColor; opacity: .45; flex: 0 0 auto; }
diff --git a/src/client/src/controllers/sessionController.ts b/src/client/src/controllers/sessionController.ts
index f7b13df..3bc4bba 100644
--- a/src/client/src/controllers/sessionController.ts
+++ b/src/client/src/controllers/sessionController.ts
@@ -62,7 +62,7 @@ export class SessionController {
this.socket.close();
this.catchupStreamSessionId = undefined;
this.clearPendingTranscriptEvents();
- this.setState({ selectedSession: undefined, messages: [], messagePageStart: 0, messagePageTotal: 0, isLoadingEarlierMessages: false, isReceivingPartialStream: false, status: undefined, activity: undefined });
+ this.setState({ selectedSession: undefined, messages: [], messagePageStart: 0, messagePageEnd: 0, messagePageTotal: 0, isLoadingEarlierMessages: false, isReceivingPartialStream: false, status: undefined, activity: undefined });
}
deselectSession(options?: { forgetRememberedSelection?: boolean | undefined; updateUrl?: boolean | undefined }) {
diff --git a/src/client/src/inputModes.test.ts b/src/client/src/inputModes.test.ts
index 717da29..228daa4 100644
--- a/src/client/src/inputModes.test.ts
+++ b/src/client/src/inputModes.test.ts
@@ -17,7 +17,10 @@ describe("inputModeForDraft", () => {
it("detects file completion contexts", () => {
expect(inputModeForDraft("open @src/main.ts")).toEqual({ kind: "file" });
expect(inputModeForDraft("open @ ")).toEqual({ kind: "file" });
+ expect(inputModeForDraft("open !@vendor/file.ts")).toEqual({ kind: "file" });
+ expect(inputModeForDraft("!@vendor/file.ts")).toEqual({ kind: "file" });
expect(inputModeForDraft("open @ \"src/main.ts")).toEqual({ kind: "file" });
+ expect(inputModeForDraft("open !@\"vendor/file.ts")).toEqual({ kind: "file" });
expect(inputModeForDraft("open \"src/main.ts")).toEqual({ kind: "normal" });
});
});
diff --git a/src/client/src/inputModes.ts b/src/client/src/inputModes.ts
index 680683b..38c0453 100644
--- a/src/client/src/inputModes.ts
+++ b/src/client/src/inputModes.ts
@@ -6,6 +6,7 @@ export type InputMode =
export function inputModeForDraft(draft: string): InputMode {
const trimmed = draft.trimStart();
+ if (trimmed.startsWith("!@")) return { kind: "file" };
if (trimmed.startsWith("!")) return { kind: "shell", excludeFromContext: trimmed.startsWith("!!") };
if (currentToken(draft).startsWith("/")) return { kind: "command" };
if (isFileCompletionContext(draft)) return { kind: "file" };
@@ -23,11 +24,11 @@ function currentToken(draft: string): string {
function isFileCompletionContext(draft: string): boolean {
const token = currentToken(draft);
- if (token.startsWith("@")) return true;
+ if (token.startsWith("@") || token.startsWith("!@")) return true;
const tokenStart = draft.length - token.length;
if (draft.slice(0, tokenStart).endsWith("@ ")) return true;
const quoteStart = draft.lastIndexOf("\"");
if (quoteStart === -1) return false;
const prefix = draft.slice(0, quoteStart);
- return prefix.endsWith("@") || prefix.endsWith("@ ");
+ return prefix.endsWith("@") || prefix.endsWith("@ ") || prefix.endsWith("!@");
}
diff --git a/src/client/src/plugins/core/actions.ts b/src/client/src/plugins/core/actions.ts
index 9583ddd..f5d046b 100644
--- a/src/client/src/plugins/core/actions.ts
+++ b/src/client/src/plugins/core/actions.ts
@@ -1,5 +1,6 @@
import { isSessionActive } from "../../../../shared/activity";
import type { AppState } from "../../appState";
+import { isCachedNewSessionInfo } from "../../cachedNewSessions";
import { isWorkspaceDeletionPending } from "../../workspaceDeletion";
import type { PluginAction } from "../types";
@@ -168,9 +169,17 @@ export function createCoreActions(): PluginAction[] {
title: "Archive Session",
description: "Archive the selected session",
group: "Session",
- enabled: (context) => context.state.selectedSession !== undefined && context.state.selectedSession.archived !== true,
+ enabled: hasArchivableSession,
run: (context) => context.archiveSession(),
},
+ {
+ id: "session.delete",
+ title: "Delete New Session",
+ description: "Delete the selected browser-cached new session",
+ group: "Session",
+ enabled: hasCachedNewSession,
+ run: (context) => context.deleteCachedNewSession(),
+ },
{
id: "session.stop",
title: "Stop Active Work",
@@ -194,3 +203,12 @@ function hasDeletableWorkspace(context: { state: AppState }): boolean {
const workspace = context.state.selectedWorkspace;
return workspace !== undefined && workspace.isGitWorktree && !workspace.isMain && !isWorkspaceDeletionPending(context.state, workspace);
}
+
+function hasArchivableSession(context: { state: AppState }): boolean {
+ const session = context.state.selectedSession;
+ return session !== undefined && session.archived !== true && !isCachedNewSessionInfo(session);
+}
+
+function hasCachedNewSession(context: { state: AppState }): boolean {
+ return isCachedNewSessionInfo(context.state.selectedSession);
+}
diff --git a/src/client/src/plugins/registry.test.ts b/src/client/src/plugins/registry.test.ts
index 0929e52..c009241 100644
--- a/src/client/src/plugins/registry.test.ts
+++ b/src/client/src/plugins/registry.test.ts
@@ -1,6 +1,7 @@
import { describe, expect, it, vi } from "vitest";
-import type { Workspace } from "../api";
+import type { SessionInfo, Workspace } from "../api";
import { initialAppState, type AppState } from "../appState";
+import { markCachedNewSessionInfo } from "../cachedNewSessions";
import { corePlugin } from "./core";
import { PluginRegistry } from "./registry";
import { themePackPlugin } from "./themes";
@@ -38,6 +39,7 @@ function createContext(statePatch: Partial