diff --git a/extensions/processes-dock/components/log-dock-component.test.ts b/extensions/processes-dock/components/log-dock-component.test.ts index 85ff9ff..eb78727 100644 --- a/extensions/processes-dock/components/log-dock-component.test.ts +++ b/extensions/processes-dock/components/log-dock-component.test.ts @@ -151,6 +151,33 @@ describe("renderLogDock", () => { expect(lines.join("\n")).toContain("warn once"); }); + it("treats expanded height as log rows, not total widget rows", () => { + const lines = renderLogDock( + snapshot({ + pinnedLines: [ + { type: "stdout", text: "line one" }, + { type: "stdout", text: "line two" }, + { type: "stdout", text: "line three" }, + ], + state: { + visibility: "expanded", + followEnabled: true, + focusedProcessId: "proc_1", + }, + }), + makeTheme(), + 100, + 2, + ); + + // Chrome is added on top of the configured log rows: + // top border + process strip + rule + 2 log rows + bottom border. + expect(lines).toHaveLength(6); + expect(lines.join("\n")).toContain("line two"); + expect(lines.join("\n")).toContain("line three"); + expect(lines.join("\n")).not.toContain("line one"); + }); + it("shows notification badges and highlights matching lines", () => { const lines = renderLogDock( snapshot({ diff --git a/extensions/processes-dock/components/log-dock-component.ts b/extensions/processes-dock/components/log-dock-component.ts index b95633d..001f6b2 100644 --- a/extensions/processes-dock/components/log-dock-component.ts +++ b/extensions/processes-dock/components/log-dock-component.ts @@ -42,14 +42,14 @@ export function renderLogDock( snapshot: LogDockSnapshot, theme: Theme, width: number, - height: number, + logRows: number, ): string[] { if (snapshot.state.visibility === "closed") return []; if (snapshot.processes.length === 0) return []; const body = snapshot.state.visibility === "expanded" - ? buildExpandedBody(snapshot, theme, Math.max(1, height - 4)) + ? buildExpandedBody(snapshot, theme, Math.max(1, logRows)) : buildCollapsedBody(snapshot, theme); return new Panel({ @@ -59,9 +59,7 @@ export function renderLogDock( padding: 0, borderStyle: (text) => theme.fg("dim", text), titleStyle: (text) => theme.fg("accent", theme.bold(text)), - }) - .render(width) - .slice(0, height); + }).render(width); } function buildCollapsedBody( diff --git a/extensions/processes-dock/widget/setup.ts b/extensions/processes-dock/widget/setup.ts index 9aa395f..836a429 100644 --- a/extensions/processes-dock/widget/setup.ts +++ b/extensions/processes-dock/widget/setup.ts @@ -5,6 +5,7 @@ import type { } from "@earendil-works/pi-coding-agent"; import { CHANNELS, + type CommandPinPayload, type ProcessesOutputChangedPayload, type ProcessProtocolNotificationPayload, } from "../../../src/protocol"; @@ -14,6 +15,7 @@ import { type ProcessLogLine, requestCombinedOutput, requestConfig, + requestProcess, requestProcessList, } from "../client"; import { renderLogDock } from "../components/log-dock-component"; @@ -124,11 +126,6 @@ export function setupDockWidgets( const pinned = selectPinnedProcess(processes, current); if (!pinned) { state.actions.setFocus(null); - } else if (!LIVE_STATUSES.has(pinned.status)) { - const latestRunning = processes.find((process) => - LIVE_STATUSES.has(process.status), - ); - if (latestRunning) state.actions.setFocus(latestRunning.id); } } @@ -142,7 +139,8 @@ export function setupDockWidgets( if ( processes.length === 0 || - (hasSeenRunningProcess && + (!state.getState().focusedProcessId && + hasSeenRunningProcess && processes.every((process) => !LIVE_STATUSES.has(process.status))) ) { state.actions.close(); @@ -287,10 +285,51 @@ export function setupDockWidgets( render(); }; + const handlePin = (payload: unknown) => { + const command = payload as CommandPinPayload; + if (!isCommandPinPayload(command)) return; + // COMMAND_PIN can arrive before the dock's throttled CHANGED refresh has + // run. Refresh the local snapshot first so expand/pin renders immediately + // against the current process list. + processes = sortProcesses(requestProcessList(events)); + // id: null unpins the dock (mirrors `/ps:pin clear`). + if (command.id === null) { + actions.setFocus(null); + actions.expand(); + render(); + safeReply(command.reply, { ok: true }); + return; + } + const process = requestProcess(events, command.id); + if (!process) { + safeReply(command.reply, { ok: false, error: "Process not found" }); + return; + } + if ( + !LIVE_STATUSES.has(process.status) && + actions.getFocusedProcessId() !== process.id + ) { + safeReply(command.reply, { + ok: false, + error: "Only running processes can be pinned", + }); + return; + } + actions.setFocus(process.id); + actions.expand(); + render(); + safeReply(command.reply, { ok: true }); + }; + disposers.push(events.on(CHANNELS.STARTED, handleStarted)); disposers.push(events.on(CHANNELS.ENDED, scheduleRefresh)); disposers.push(events.on(CHANNELS.CHANGED, scheduleRefresh)); disposers.push(events.on(CHANNELS.OUTPUT_CHANGED, handleOutputChanged)); + disposers.push( + events.on(CHANNELS.COMMAND_PIN, (payload) => { + handlePin(payload); + }), + ); disposers.push( events.on(CHANNELS.NOTIFICATION, (payload) => { if (!isLogMatchNotification(payload)) return; @@ -372,3 +411,20 @@ function isLogMatchNotification( typeof payload.logMatch.line === "string" ); } + +function isCommandPinPayload(payload: unknown): payload is CommandPinPayload { + return ( + isRecord(payload) && + (typeof payload.id === "string" || payload.id === null) && + typeof payload.reply === "function" + ); +} + +function safeReply(reply: (result: T) => void, result: T): void { + try { + reply(result); + } catch { + // Reply callbacks are owned by the requester. + return; + } +} diff --git a/extensions/processes/settings/build-sections.ts b/extensions/processes/settings/build-sections.ts index 340c93e..7e2c49a 100644 --- a/extensions/processes/settings/build-sections.ts +++ b/extensions/processes/settings/build-sections.ts @@ -235,7 +235,7 @@ function buildDockDetailItem( return { id: "dock.details", label: "Dock", - currentValue: `${dockDefaultState} · ${dockHeight} lines`, + currentValue: `${dockDefaultState} · ${dockHeight} log lines`, description: "Open focused settings for the /ps:dock widget above the editor.", submenu: (_current, done) => { @@ -276,8 +276,9 @@ function buildDockDetailItem( { id: "dock.height", type: "text", - label: "Number of lines displayed", - description: "Maximum rows rendered by the dock.", + label: "Log lines displayed", + description: + "Maximum log/content rows shown inside the dock. Border, title, process strip, and separator are added on top.", getValue: () => nextDockHeight, setValue: (value) => { nextDockHeight = value; @@ -287,7 +288,7 @@ function buildDockDetailItem( }, ], getDoneSummary: () => - `${nextDockDefaultState} · ${parsePositiveInt(nextDockHeight)} lines`, + `${nextDockDefaultState} · ${parsePositiveInt(nextDockHeight)} log lines`, onDone: (summary) => done(summary), }); }, diff --git a/src/protocol/channels.ts b/src/protocol/channels.ts index bf3e804..7520a7b 100644 --- a/src/protocol/channels.ts +++ b/src/protocol/channels.ts @@ -17,6 +17,8 @@ export const CHANNELS = { // Command channels (UI -> core, callback) COMMAND_KILL: "processes:command:kill", COMMAND_CLEAR: "processes:command:clear", + // Pin handled by the dock extension, if loaded. + COMMAND_PIN: "processes:command:pin", // Log subscription channels LOGS_SUBSCRIBE: "processes:logs:subscribe", diff --git a/src/protocol/commands.ts b/src/protocol/commands.ts index e75c940..8c754bb 100644 --- a/src/protocol/commands.ts +++ b/src/protocol/commands.ts @@ -12,3 +12,13 @@ export interface CommandKillPayload { export interface CommandClearPayload { reply: (cleared: number) => void; } + +// UI emits; the dock extension (if loaded) handles it by pinning the process +// to the dock. Pass `id: null` to unpin. If the dock extension is not +// registered, no listener replies. +export interface CommandPinPayload { + id: string | null; + reply: (result: CommandPinResult) => void; +} + +export type CommandPinResult = { ok: true } | { ok: false; error: string }; diff --git a/src/protocol/index.ts b/src/protocol/index.ts index 7847db4..e330b22 100644 --- a/src/protocol/index.ts +++ b/src/protocol/index.ts @@ -5,7 +5,12 @@ export type { ProcessesStartedPayload, } from "./broadcasts"; export { CHANNELS } from "./channels"; -export type { CommandClearPayload, CommandKillPayload } from "./commands"; +export type { + CommandClearPayload, + CommandKillPayload, + CommandPinPayload, + CommandPinResult, +} from "./commands"; export type { LogsChunkPayload, LogsSubscribePayload,