@aliou/pi-processes 0.10.9 → 0.11.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (40) hide show
  1. package/extensions/processes/client.ts +24 -2
  2. package/extensions/processes/commands/overview.ts +1 -1
  3. package/extensions/processes/components/overview-component.ts +121 -51
  4. package/extensions/processes/config/migrations/001-v0-9-4-to-v0-10-0-config.ts +5 -8
  5. package/extensions/processes/config/types.ts +1 -1
  6. package/extensions/processes/handlers/commands.ts +21 -41
  7. package/extensions/processes/handlers/notifications.ts +3 -37
  8. package/extensions/processes/handlers/requests.ts +1 -54
  9. package/extensions/processes/handlers/subscriptions.ts +2 -35
  10. package/extensions/processes/hooks/event-bridge.ts +1 -1
  11. package/extensions/processes/index.ts +2 -2
  12. package/extensions/processes/notifications/service.ts +4 -1
  13. package/extensions/processes/notifications/types.ts +2 -2
  14. package/extensions/processes/tools/notify.ts +37 -130
  15. package/extensions/processes/tools/schema.ts +5 -2
  16. package/extensions/processes/tools/update/index.ts +15 -40
  17. package/extensions/processes-debug/index.ts +67 -0
  18. package/extensions/processes-dock/client.ts +2 -2
  19. package/extensions/processes-dock/widget/setup.ts +12 -37
  20. package/extensions/processes-logs/client.ts +2 -2
  21. package/extensions/processes-logs/commands/logs.ts +1 -1
  22. package/extensions/processes-logs/components/log-file-viewer.ts +135 -8
  23. package/extensions/processes-logs/components/log-overlay-component.ts +156 -82
  24. package/extensions/processes-logs/logs-client.ts +2 -23
  25. package/extensions/shared/log-line.ts +49 -2
  26. package/{src → extensions/shared}/protocol/broadcasts.ts +1 -1
  27. package/{src → extensions/shared}/protocol/channels.ts +1 -0
  28. package/{src → extensions/shared}/protocol/commands.ts +12 -1
  29. package/{src → extensions/shared}/protocol/index.ts +2 -0
  30. package/{src → extensions/shared}/protocol/notifications.ts +1 -1
  31. package/{src → extensions/shared}/protocol/requests.ts +1 -1
  32. package/extensions/shared/shortcut-hints.ts +150 -0
  33. package/extensions/shared/shortcuts-overlay.ts +229 -0
  34. package/extensions/shared/truncate.ts +189 -0
  35. package/package.json +3 -3
  36. package/src/utils/command-executor.ts +2 -1
  37. package/extensions/shared/output-payload.ts +0 -28
  38. package/src/get-manager.ts +0 -15
  39. package/src/utils/is-record.ts +0 -3
  40. /package/{src → extensions/shared}/protocol/logs.ts +0 -0
@@ -16,7 +16,7 @@ import {
16
16
  sanitizeForDisplay,
17
17
  stripSgr,
18
18
  } from "./display-text";
19
- import { truncateToWidth } from "./truncate";
19
+ import { truncateToWidth, wrapToWidth } from "./truncate";
20
20
 
21
21
  export interface DisplayLogLine {
22
22
  type: "stdout" | "stderr";
@@ -56,13 +56,60 @@ export function renderLogLine(
56
56
  const prefixWidth = visibleWidth(prefix);
57
57
  const textWidth = Math.max(1, width - prefixWidth);
58
58
  const safe = sanitizeForDisplay(line.text);
59
+ // Use "→" as the truncation indicator so the user can see that a line
60
+ // was clipped (wrap mode is available via the `w` key in the overlay).
59
61
  const text = closeSgr(
60
- truncateToWidth(plain ? stripSgr(safe) : safe, textWidth, "", true),
62
+ truncateToWidth(plain ? stripSgr(safe) : safe, textWidth, "", true),
61
63
  );
62
64
 
63
65
  return `${prefix}${toneLogText(text, line.type, emphasis, theme)}`;
64
66
  }
65
67
 
68
+ /**
69
+ * Wrap a log line into multiple display rows instead of truncating.
70
+ *
71
+ * Each returned row is toned (by stream / match state) and padded to `width`.
72
+ * SGR state is carried across wrapped chunks so colours survive wrapping.
73
+ * Continuation rows (every row after the first) are indented with a dim
74
+ * `continuationPrefix` so the user can visually distinguish a wrapped chunk
75
+ * from a new log line — matching `less`/`journalctl` behaviour.
76
+ * Used by the `/ps:logs` overlay soft-wrap mode; `renderLogLine` (truncate)
77
+ * remains the default for the `/ps` preview and dock.
78
+ */
79
+ export function renderLogLineWrap(
80
+ line: DisplayLogLine,
81
+ options: RenderLogLineOptions,
82
+ ): string[] {
83
+ const {
84
+ theme,
85
+ width,
86
+ emphasis = "none",
87
+ prefix = "",
88
+ plain = false,
89
+ } = options;
90
+ if (width <= 0) return [];
91
+
92
+ const prefixWidth = visibleWidth(prefix);
93
+ const textWidth = Math.max(1, width - prefixWidth);
94
+ const safe = sanitizeForDisplay(line.text);
95
+ const source = plain ? stripSgr(safe) : safe;
96
+
97
+ // Continuation rows get a dim arrow indent so wrapped chunks are
98
+ // visually distinct from new log lines.
99
+ const contMarker = "↳ ";
100
+ const contIndent = visibleWidth(contMarker);
101
+
102
+ const wrapped = wrapToWidth(source, textWidth, contIndent);
103
+
104
+ return wrapped.map((row, index) => {
105
+ const toned = toneLogText(row, line.type, emphasis, theme);
106
+ if (index === 0) {
107
+ return `${prefix}${toned}`;
108
+ }
109
+ return `${theme.fg("dim", contMarker)}${toned}`;
110
+ });
111
+ }
112
+
66
113
  /** Text of a log line as the views display it, for match comparisons. */
67
114
  export function displayTextOf(line: DisplayLogLine): string {
68
115
  return plainTextForDisplay(line.text);
@@ -1,4 +1,4 @@
1
- import type { ProcessInfo } from "../types";
1
+ import type { ProcessInfo } from "../../../src/types";
2
2
 
3
3
  // Core emits, UI listens.
4
4
 
@@ -15,6 +15,7 @@ export const CHANNELS = {
15
15
  REQUEST_CONFIG: "processes:request:config",
16
16
 
17
17
  // Command channels (UI -> core, callback)
18
+ COMMAND_START: "processes:command:start",
18
19
  COMMAND_KILL: "processes:command:kill",
19
20
  COMMAND_CLEAR: "processes:command:clear",
20
21
  // Pin handled by the dock extension, if loaded.
@@ -1,7 +1,18 @@
1
- import type { KillResult } from "../types";
1
+ import type { KillResult, ProcessInfo } from "../../../src/types";
2
2
 
3
3
  // UI emits, core handles then calls reply.
4
4
 
5
+ export interface CommandStartPayload {
6
+ name: string;
7
+ command: string;
8
+ cwd?: string;
9
+ reply: (result: CommandStartResult) => void;
10
+ }
11
+
12
+ export type CommandStartResult =
13
+ | { ok: true; process: ProcessInfo }
14
+ | { ok: false; error: string };
15
+
5
16
  export interface CommandKillPayload {
6
17
  id: string;
7
18
  signal?: NodeJS.Signals;
@@ -10,6 +10,8 @@ export type {
10
10
  CommandKillPayload,
11
11
  CommandPinPayload,
12
12
  CommandPinResult,
13
+ CommandStartPayload,
14
+ CommandStartResult,
13
15
  } from "./commands";
14
16
  export type {
15
17
  LogsChunkPayload,
@@ -2,7 +2,7 @@ import type {
2
2
  ProcessEndReason,
3
3
  ProcessSignalInfo,
4
4
  ProcessStatus,
5
- } from "../types";
5
+ } from "../../../src/types";
6
6
 
7
7
  /**
8
8
  * Notification event payload broadcast on {@link CHANNELS.NOTIFICATION}.
@@ -1,4 +1,4 @@
1
- import type { ProcessInfo } from "../types";
1
+ import type { ProcessInfo } from "../../../src/types";
2
2
 
3
3
  // UI emits, core listens and calls reply synchronously.
4
4
  // Reply callbacks make this an in-process protocol, not serializable IPC/RPC.
@@ -0,0 +1,150 @@
1
+ /**
2
+ * Footer shortcut-hint rendering shared by the `/ps` overview and the
3
+ * `/ps:logs` overlay footers.
4
+ *
5
+ * Each hint renders in one of two modes:
6
+ *
7
+ * - Minimal: when the hint's key is a single character that occurs in its
8
+ * label, only the word is shown and every occurrence of the key letter is
9
+ * rendered in accent + bold. "w wrap" becomes "wrap" with a bold accent
10
+ * "w"; the remainder of the word keeps the segment style (so a stateful
11
+ * label like "wrap" can stay dim while off and accent while on).
12
+ * - Classic: `<dim key> <label>` (e.g. "j/k scroll"), used whenever the key
13
+ * is not a single letter found in the label ("q close", "/ search",
14
+ * "N prev").
15
+ *
16
+ * `renderShortcutHints` additionally collapses the bar to a leading "? more"
17
+ * affordance when the full list does not fit the available width: the "?"
18
+ * key opens the full shortcuts overlay (see shortcuts-overlay.ts).
19
+ *
20
+ * Keys and labels are static, trusted UI copy — never process output — so
21
+ * they do not go through the untrusted-text sanitizers.
22
+ */
23
+
24
+ import type { Theme } from "@earendil-works/pi-coding-agent";
25
+ import { type Component, visibleWidth } from "@earendil-works/pi-tui";
26
+
27
+ import { truncateToWidth } from "./truncate";
28
+
29
+ /** Style applied to a label segment. */
30
+ export type ShortcutLabelStyle = "plain" | "dim" | "accent";
31
+
32
+ export interface ShortcutLabelSegment {
33
+ /** Segment text (trusted, static UI copy — not process output). */
34
+ text: string;
35
+ style?: ShortcutLabelStyle;
36
+ }
37
+
38
+ export interface ShortcutHint {
39
+ /** Key label as shown to the user, e.g. "w", "j/k", "/", "pgup/pgdn". */
40
+ key: string;
41
+ /**
42
+ * Action label: a plain word, or pre-styled segments for stateful labels
43
+ * (such as the stream filter's "stdout+stderr" whose halves dim and
44
+ * accent independently).
45
+ */
46
+ label: string | ShortcutLabelSegment[];
47
+ }
48
+
49
+ /** Separator between hints in a rendered bar. */
50
+ const SEPARATOR = " ";
51
+
52
+ /** The key that opens the shortcuts overlay. */
53
+ export const SHORTCUTS_KEY = "?";
54
+
55
+ function styleSegment(
56
+ text: string,
57
+ style: ShortcutLabelStyle,
58
+ theme: Theme,
59
+ ): string {
60
+ if (text.length === 0) return "";
61
+ if (style === "dim") return theme.fg("dim", text);
62
+ if (style === "accent") return theme.fg("accent", text);
63
+ return text;
64
+ }
65
+
66
+ function segmentsOf(label: ShortcutHint["label"]): ShortcutLabelSegment[] {
67
+ return typeof label === "string" ? [{ text: label }] : label;
68
+ }
69
+
70
+ /** True when the hint can render in minimal mode (single-char key in label). */
71
+ function isMinimal(hint: ShortcutHint): boolean {
72
+ if (hint.key.length !== 1) return false;
73
+ return segmentsOf(hint.label).some((segment) =>
74
+ segment.text.includes(hint.key),
75
+ );
76
+ }
77
+
78
+ /** Render a single hint (see module doc for the two modes). */
79
+ function renderShortcutHint(hint: ShortcutHint, theme: Theme): string {
80
+ if (isMinimal(hint)) {
81
+ const key = theme.fg("accent", theme.bold(hint.key));
82
+ return segmentsOf(hint.label)
83
+ .map((segment) => {
84
+ const style = segment.style ?? "plain";
85
+ return segment.text
86
+ .split(hint.key)
87
+ .map((piece) => styleSegment(piece, style, theme))
88
+ .join(key);
89
+ })
90
+ .join("");
91
+ }
92
+
93
+ const label =
94
+ typeof hint.label === "string"
95
+ ? hint.label
96
+ : segmentsOf(hint.label)
97
+ .map((segment) =>
98
+ styleSegment(segment.text, segment.style ?? "plain", theme),
99
+ )
100
+ .join("");
101
+ return `${theme.fg("dim", hint.key)} ${label}`;
102
+ }
103
+
104
+ /** Render the "? more" affordance shown when the bar overflows. */
105
+ function renderMoreHint(theme: Theme): string {
106
+ return `${theme.fg("accent", theme.bold(SHORTCUTS_KEY))}${theme.fg("dim", " more")}`;
107
+ }
108
+
109
+ /**
110
+ * Render a hint bar for the given width. When the full list fits, the hints
111
+ * are joined as-is. When it does not, the bar starts with "? more" (the
112
+ * shortcut that opens the shortcuts overlay) followed by every hint that
113
+ * still fits, in list order.
114
+ */
115
+ export function renderShortcutHints(
116
+ hints: ShortcutHint[],
117
+ theme: Theme,
118
+ width: number,
119
+ ): string {
120
+ const rendered = hints.map((hint) => renderShortcutHint(hint, theme));
121
+ const full = rendered.join(SEPARATOR);
122
+ if (visibleWidth(full) <= width) {
123
+ return truncateToWidth(full, width, "", true);
124
+ }
125
+
126
+ let line = renderMoreHint(theme);
127
+ for (const hint of rendered) {
128
+ const next = `${line}${SEPARATOR}${hint}`;
129
+ if (visibleWidth(next) > width) break;
130
+ line = next;
131
+ }
132
+ return truncateToWidth(line, width, "", true);
133
+ }
134
+
135
+ /**
136
+ * Render a footer shortcut-hint bar. Hints are fetched through a getter so
137
+ * stateful labels (wrap on/off, active stream) stay fresh across renders.
138
+ */
139
+ export class ShortcutHintsComponent implements Component {
140
+ constructor(
141
+ private readonly getHints: () => ShortcutHint[],
142
+ private readonly theme: Theme,
143
+ ) {}
144
+
145
+ render(width: number): string[] {
146
+ return [renderShortcutHints(this.getHints(), this.theme, width)];
147
+ }
148
+
149
+ invalidate(): void {}
150
+ }
@@ -0,0 +1,229 @@
1
+ /**
2
+ * The "? more" shortcuts overlay: a small centered panel stacked on top of
3
+ * the `/ps` overview or the `/ps:logs` overlay that lists every shortcut the
4
+ * footer may have truncated away.
5
+ *
6
+ * Layout follows herdr's keybinds panel: a title row (bold lowercase
7
+ * `keybinds` on the left, an inverse `esc close` pill on the right), then
8
+ * grouped sections with accent lowercase headers (`scrolling`, `view`, …),
9
+ * each row rendering keys in accent and the description in the normal
10
+ * foreground, descriptions aligned in one column shared by every group.
11
+ *
12
+ * Opened through `showShortcutsOverlay`, which pushes the component onto the
13
+ * TUI overlay stack (`tui.showOverlay`) so it renders above the calling
14
+ * overlay and captures keyboard focus; dismissing it restores focus to the
15
+ * caller. Any of `?`, `esc`, `enter`, `q`, or `ctrl+c` dismisses it.
16
+ */
17
+
18
+ import type { Theme } from "@earendil-works/pi-coding-agent";
19
+ import {
20
+ type Component,
21
+ Key,
22
+ matchesKey,
23
+ type OverlayHandle,
24
+ type TUI,
25
+ visibleWidth,
26
+ } from "@earendil-works/pi-tui";
27
+
28
+ import { truncateToWidth } from "./truncate";
29
+
30
+ /** One shortcut row: keys (already display-formatted) + description. */
31
+ export interface ShortcutRow {
32
+ /** Key tokens joined with " / ", e.g. "j / k", "ctrl+u / ctrl+d". */
33
+ keys: string;
34
+ /** Action description, e.g. "line up / down". */
35
+ description: string;
36
+ }
37
+
38
+ /** A named group of shortcut rows. */
39
+ export interface ShortcutGroup {
40
+ /** Lowercase group header, e.g. "scrolling". */
41
+ title: string;
42
+ rows: ShortcutRow[];
43
+ }
44
+
45
+ export interface ShortcutsOverlayOptions {
46
+ theme: Theme;
47
+ /** Groups to render, in order (the title row and close pill are fixed). */
48
+ groups: ShortcutGroup[];
49
+ /** Called when the user dismisses the overlay. */
50
+ onDismiss: () => void;
51
+ }
52
+
53
+ const OVERLAY_TITLE = "keybinds";
54
+ const CLOSE_PILL = " esc close ";
55
+ const KEY_COLUMN_GAP = 3;
56
+ const PANEL_PADDING = 1;
57
+ const MIN_OVERLAY_WIDTH = 36;
58
+ const MAX_OVERLAY_WIDTH = 72;
59
+
60
+ export class ShortcutsOverlayComponent implements Component {
61
+ private readonly contentWidth: number;
62
+
63
+ constructor(private readonly opts: ShortcutsOverlayOptions) {
64
+ this.contentWidth = computeKeyColumn(opts.groups);
65
+ }
66
+
67
+ render(width: number): string[] {
68
+ const theme = this.opts.theme;
69
+ const inner = Math.max(1, width - 2); // borders
70
+ const pad = Math.max(0, Math.min(PANEL_PADDING, inner));
71
+ const contentWidth = Math.max(1, inner - pad * 2);
72
+
73
+ const lines: string[] = [];
74
+ lines.push(borderTop(theme, inner, OVERLAY_TITLE));
75
+ lines.push(
76
+ contentLine(theme, inner, pad, this.renderTitleRow(contentWidth)),
77
+ );
78
+ let first = true;
79
+ for (const group of this.opts.groups) {
80
+ if (!first) lines.push(blankLine(inner));
81
+ first = false;
82
+ lines.push(
83
+ contentLine(
84
+ theme,
85
+ inner,
86
+ pad,
87
+ theme.fg("accent", theme.bold(group.title)),
88
+ ),
89
+ );
90
+ const keyColumn = this.contentWidth;
91
+ for (const row of group.rows) {
92
+ const keys = theme.fg("accent", row.keys);
93
+ const gap = " ".repeat(
94
+ Math.max(1, keyColumn - visibleWidth(row.keys) + KEY_COLUMN_GAP - 1),
95
+ );
96
+ const description = row.description;
97
+ lines.push(
98
+ contentLine(theme, inner, pad, `${keys}${gap}${description}`),
99
+ );
100
+ }
101
+ }
102
+ lines.push(blankLine(inner));
103
+ lines.push(borderBottom(theme, inner));
104
+ return lines;
105
+ }
106
+
107
+ /**
108
+ * Title row: the inverse `esc close` pill, right-aligned. The bold title
109
+ * itself lives in the top border, so it is not duplicated here.
110
+ */
111
+ private renderTitleRow(width: number): string {
112
+ const theme = this.opts.theme;
113
+ const pill = theme.bg("selectedBg", theme.fg("dim", CLOSE_PILL));
114
+ const gap = Math.max(0, width - visibleWidth(pill));
115
+ return truncateToWidth(`${" ".repeat(gap)}${pill}`, width, "", true);
116
+ }
117
+
118
+ handleInput(data: string): void {
119
+ if (
120
+ matchesKey(data, Key.escape) ||
121
+ matchesKey(data, Key.ctrl("c")) ||
122
+ matchesKey(data, Key.enter) ||
123
+ data === "q" ||
124
+ data === "?"
125
+ ) {
126
+ this.opts.onDismiss();
127
+ }
128
+ }
129
+
130
+ invalidate(): void {
131
+ return;
132
+ }
133
+ }
134
+
135
+ function blankLine(inner: number): string {
136
+ return `│${" ".repeat(inner)}│`;
137
+ }
138
+
139
+ function borderTop(theme: Theme, inner: number, title: string): string {
140
+ const styled = theme.fg("dim", ` ${theme.bold(title)} `);
141
+ const titleWidth = visibleWidth(styled);
142
+ const left = Math.max(0, Math.floor((inner - titleWidth) / 2));
143
+ const right = Math.max(0, inner - titleWidth - left);
144
+ return (
145
+ theme.fg("dim", `╭${"─".repeat(left)}`) +
146
+ styled +
147
+ theme.fg("dim", `${"─".repeat(right)}╮`)
148
+ );
149
+ }
150
+
151
+ function borderBottom(theme: Theme, inner: number): string {
152
+ return theme.fg("dim", `╰${"─".repeat(inner)}╯`);
153
+ }
154
+
155
+ function contentLine(
156
+ theme: Theme,
157
+ inner: number,
158
+ pad: number,
159
+ content: string,
160
+ ): string {
161
+ const line =
162
+ " ".repeat(pad) +
163
+ truncateToWidth(content, Math.max(0, inner - pad * 2), "", true) +
164
+ " ".repeat(pad);
165
+ return theme.fg("dim", "│") + line + theme.fg("dim", "│");
166
+ }
167
+
168
+ /** Width of the aligned key column: longest key row across all groups. */
169
+ function computeKeyColumn(groups: ShortcutGroup[]): number {
170
+ return Math.max(
171
+ ...groups.flatMap((group) =>
172
+ group.rows.map((row) => visibleWidth(row.keys)),
173
+ ),
174
+ 1,
175
+ );
176
+ }
177
+
178
+ /**
179
+ * Natural panel width for a group list: key column + gap + longest
180
+ * description, plus padding and borders, clamped to sane bounds.
181
+ */
182
+ export function computeShortcutsOverlayWidth(groups: ShortcutGroup[]): number {
183
+ const keyColumn = computeKeyColumn(groups);
184
+ const longest = Math.max(
185
+ ...groups.flatMap((group) =>
186
+ group.rows.map(
187
+ (row) => keyColumn + KEY_COLUMN_GAP - 1 + visibleWidth(row.description),
188
+ ),
189
+ ),
190
+ 1,
191
+ );
192
+ return Math.min(
193
+ MAX_OVERLAY_WIDTH,
194
+ Math.max(MIN_OVERLAY_WIDTH, longest + PANEL_PADDING * 2 + 2),
195
+ );
196
+ }
197
+
198
+ /**
199
+ * Push the shortcuts overlay onto the TUI overlay stack. Returns a disposer
200
+ * that hides it; safe to call more than once.
201
+ */
202
+ export function showShortcutsOverlay(
203
+ tui: TUI,
204
+ options: Omit<ShortcutsOverlayOptions, "onDismiss">,
205
+ ): () => void {
206
+ let handle: OverlayHandle | null = null;
207
+ let dismissed = false;
208
+
209
+ const dismiss = () => {
210
+ if (dismissed) return;
211
+ dismissed = true;
212
+ handle?.hide();
213
+ tui.requestRender();
214
+ };
215
+
216
+ const component = new ShortcutsOverlayComponent({
217
+ ...options,
218
+ onDismiss: dismiss,
219
+ });
220
+
221
+ handle = tui.showOverlay(component, {
222
+ anchor: "center",
223
+ width: computeShortcutsOverlayWidth(options.groups),
224
+ maxHeight: "80%",
225
+ margin: 2,
226
+ });
227
+
228
+ return dismiss;
229
+ }