@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
@@ -9,10 +9,16 @@ import {
9
9
  displayTextOf,
10
10
  type LogLineEmphasis,
11
11
  renderLogLine,
12
+ renderLogLineWrap,
12
13
  } from "../../shared/log-line";
13
14
  import { truncateToWidth } from "../../shared/truncate";
14
15
  import type { ProcessLogLine } from "../logs-client";
15
16
 
17
+ interface DisplayRow {
18
+ text: string;
19
+ logicalIndex: number;
20
+ }
21
+
16
22
  export type StreamFilter = "both" | "stdout" | "stderr";
17
23
 
18
24
  interface LogFileViewerOptions {
@@ -30,6 +36,8 @@ export class LogFileViewer {
30
36
  private searchMatches: number[] = [];
31
37
  private searchCurrentMatch = -1;
32
38
  private centerTarget: number | null = null;
39
+ private wrapEnabled = false;
40
+ private lastRenderWidth = 0;
33
41
  private readonly notifyLines = new Set<string>();
34
42
 
35
43
  constructor(
@@ -59,12 +67,11 @@ export class LogFileViewer {
59
67
  }
60
68
 
61
69
  scrollBy(delta: number): void {
62
- const visible = this.visibleLines();
63
70
  this.follow = false;
64
- this.anchorEnd ??= visible.length;
71
+ this.anchorEnd ??= this.totalDisplayRows(0);
65
72
  this.anchorEnd = Math.max(
66
73
  0,
67
- Math.min(visible.length, this.anchorEnd - delta),
74
+ Math.min(this.totalDisplayRows(0), this.anchorEnd - delta),
68
75
  );
69
76
  }
70
77
 
@@ -75,12 +82,12 @@ export class LogFileViewer {
75
82
 
76
83
  scrollToBottom(): void {
77
84
  this.follow = false;
78
- this.anchorEnd = this.visibleLines().length;
85
+ this.anchorEnd = this.totalDisplayRows(0);
79
86
  }
80
87
 
81
88
  toggleFollow(): boolean {
82
89
  this.follow = !this.follow;
83
- this.anchorEnd = this.follow ? null : this.visibleLines().length;
90
+ this.anchorEnd = this.follow ? null : this.totalDisplayRows(0);
84
91
  return this.follow;
85
92
  }
86
93
 
@@ -88,6 +95,18 @@ export class LogFileViewer {
88
95
  return this.follow;
89
96
  }
90
97
 
98
+ toggleWrap(): boolean {
99
+ this.wrapEnabled = !this.wrapEnabled;
100
+ // Reset anchor so the viewport snaps to the latest content in the new
101
+ // display-row space (row counts change when wrap toggles).
102
+ this.anchorEnd = this.follow ? null : this.totalDisplayRows(0);
103
+ return this.wrapEnabled;
104
+ }
105
+
106
+ isWrapEnabled(): boolean {
107
+ return this.wrapEnabled;
108
+ }
109
+
91
110
  cycleStreamFilter(): StreamFilter {
92
111
  this.streamFilter =
93
112
  this.streamFilter === "both"
@@ -95,7 +114,7 @@ export class LogFileViewer {
95
114
  : this.streamFilter === "stdout"
96
115
  ? "stderr"
97
116
  : "both";
98
- this.anchorEnd = this.visibleLines().length;
117
+ this.anchorEnd = this.totalDisplayRows(0);
99
118
  this.refreshMatches();
100
119
  return this.streamFilter;
101
120
  }
@@ -106,7 +125,7 @@ export class LogFileViewer {
106
125
 
107
126
  setSearch(query: string): void {
108
127
  this.follow = false;
109
- this.anchorEnd ??= this.visibleLines().length;
128
+ this.anchorEnd ??= this.totalDisplayRows(0);
110
129
  this.searchQuery = query;
111
130
  this.refreshMatches();
112
131
  if (this.searchMatches.length > 0) {
@@ -168,11 +187,24 @@ export class LogFileViewer {
168
187
  }
169
188
 
170
189
  render(width: number, height: number): string[] {
190
+ this.lastRenderWidth = width;
171
191
  const visible = this.visibleLines();
172
192
  if (visible.length === 0) {
173
193
  return this.renderEmpty(width, height);
174
194
  }
175
195
 
196
+ if (!this.wrapEnabled) {
197
+ return this.renderTruncated(visible, width, height);
198
+ }
199
+
200
+ return this.renderWrapped(visible, width, height);
201
+ }
202
+
203
+ private renderTruncated(
204
+ visible: ProcessLogLine[],
205
+ width: number,
206
+ height: number,
207
+ ): string[] {
176
208
  if (this.centerTarget !== null) {
177
209
  const half = Math.floor(height / 2);
178
210
  this.anchorEnd = Math.min(visible.length, this.centerTarget + half + 1);
@@ -186,6 +218,11 @@ export class LogFileViewer {
186
218
  visible.length,
187
219
  Math.max(Math.min(height, visible.length), rawEnd),
188
220
  );
221
+ // Write the clamped end back so the anchor always matches the screen.
222
+ // Otherwise scrolling mutates a stale anchor while the clamp above
223
+ // swallows the change, leaving a dead zone of up to a screenful of
224
+ // scroll keypresses.
225
+ if (!this.follow && this.anchorEnd !== null) this.anchorEnd = end;
189
226
  const start = Math.max(0, end - height);
190
227
  const matchSet = new Set(this.searchMatches);
191
228
  const currentMatchIndex =
@@ -211,12 +248,81 @@ export class LogFileViewer {
211
248
  return rendered.slice(-height);
212
249
  }
213
250
 
251
+ private renderWrapped(
252
+ visible: ProcessLogLine[],
253
+ width: number,
254
+ height: number,
255
+ ): string[] {
256
+ // Build a flat list of display rows from all visible logical lines.
257
+ // Each entry carries its source logical-line index so emphasis can be
258
+ // applied per logical line (not per wrapped chunk).
259
+ const matchSet = new Set(this.searchMatches);
260
+ const currentMatchIndex =
261
+ this.searchCurrentMatch >= 0
262
+ ? this.searchMatches[this.searchCurrentMatch]
263
+ : undefined;
264
+
265
+ const rows: DisplayRow[] = [];
266
+ // Track the starting display-row offset of each logical line so we can
267
+ // resolve centerTarget (a logical-line index) to a display row.
268
+ const logicalToDisplayRow: number[] = [];
269
+
270
+ for (let index = 0; index < visible.length; index++) {
271
+ logicalToDisplayRow[index] = rows.length;
272
+ const line = visible[index];
273
+ const emphasis: LogLineEmphasis =
274
+ index === currentMatchIndex
275
+ ? "search-current"
276
+ : matchSet.has(index)
277
+ ? "search"
278
+ : this.notifyLines.has(line.text) ||
279
+ this.notifyLines.has(displayTextOf(line))
280
+ ? "notify"
281
+ : "none";
282
+ const wrapped = renderLogLineWrap(line, {
283
+ theme: this.theme,
284
+ width,
285
+ emphasis,
286
+ });
287
+ for (const text of wrapped) {
288
+ rows.push({ text, logicalIndex: index });
289
+ }
290
+ }
291
+
292
+ const totalDisplayRows = rows.length;
293
+
294
+ // Resolve centerTarget from logical-line index to display-row index.
295
+ if (this.centerTarget !== null) {
296
+ const half = Math.floor(height / 2);
297
+ const displayRow = logicalToDisplayRow[this.centerTarget] ?? 0;
298
+ this.anchorEnd = Math.min(totalDisplayRows, displayRow + half + 1);
299
+ this.centerTarget = null;
300
+ }
301
+
302
+ const rawEnd = this.follow
303
+ ? totalDisplayRows
304
+ : (this.anchorEnd ?? totalDisplayRows);
305
+ const end = Math.min(
306
+ totalDisplayRows,
307
+ Math.max(Math.min(height, totalDisplayRows), rawEnd),
308
+ );
309
+ // Same anchor normalization as the truncated path: keep the stored
310
+ // anchor truthful so scrollBy starts from the rendered bottom edge.
311
+ if (!this.follow && this.anchorEnd !== null) this.anchorEnd = end;
312
+ const start = Math.max(0, end - height);
313
+
314
+ const rendered = rows.slice(start, end).map((row) => row.text);
315
+
316
+ while (rendered.length < height) rendered.unshift("");
317
+ return rendered.slice(-height);
318
+ }
319
+
214
320
  getStatusParts(): { left: string[]; right: string[] } {
215
321
  const dim = (value: string) => this.theme.fg("dim", value);
216
322
  const accent = (value: string) => this.theme.fg("accent", value);
217
323
  const error = (value: string) => this.theme.fg("error", value);
218
324
  const visible = this.visibleLines();
219
- const total = visible.length;
325
+ const total = this.wrapEnabled ? this.totalDisplayRows(0) : visible.length;
220
326
 
221
327
  const left: string[] = [];
222
328
  const search = this.getSearchInfo();
@@ -239,6 +345,7 @@ export class LogFileViewer {
239
345
  right.push(dim(`${pct}% L${end}/${total}`));
240
346
  }
241
347
  if (this.streamFilter !== "both") right.push(dim(`[${this.streamFilter}]`));
348
+ if (this.wrapEnabled) right.push(dim("wrap"));
242
349
 
243
350
  return { left, right };
244
351
  }
@@ -257,6 +364,26 @@ export class LogFileViewer {
257
364
  return this.lines.filter((line) => line.type === this.streamFilter);
258
365
  }
259
366
 
367
+ /**
368
+ * Total display rows for the visible lines at the last render width.
369
+ * When wrapping is off (or no width is known), equals the logical-line
370
+ * count. The `fallbackWidth` is used before the first render.
371
+ */
372
+ private totalDisplayRows(fallbackWidth: number): number {
373
+ if (!this.wrapEnabled) return this.visibleLines().length;
374
+ const width = this.lastRenderWidth || fallbackWidth;
375
+ if (width <= 0) return this.visibleLines().length;
376
+ let total = 0;
377
+ for (const line of this.visibleLines()) {
378
+ const wrapped = renderLogLineWrap(line, {
379
+ theme: this.theme,
380
+ width,
381
+ });
382
+ total += Math.max(1, wrapped.length);
383
+ }
384
+ return total;
385
+ }
386
+
260
387
  private refreshMatches(): void {
261
388
  if (!this.searchQuery) {
262
389
  this.searchMatches = [];
@@ -4,21 +4,29 @@ import {
4
4
  type Component,
5
5
  Input,
6
6
  Key,
7
- matchesKey,
7
+ parseKey,
8
8
  type TUI,
9
9
  visibleWidth,
10
10
  } from "@earendil-works/pi-tui";
11
+ import { LIVE_STATUSES, type ProcessInfo } from "../../../src/types";
12
+ import { formatRuntime } from "../../../src/utils/format";
13
+ import { truncateForDisplay } from "../../shared/display-text";
14
+ import { renderProcessTab } from "../../shared/process-tabs";
11
15
  import {
12
16
  CHANNELS,
13
17
  type ProcessesChangedPayload,
14
18
  type ProcessProtocolConfig,
15
19
  type ProcessProtocolNotificationPayload,
16
- } from "../../../src/protocol";
17
- import { LIVE_STATUSES, type ProcessInfo } from "../../../src/types";
18
- import { formatRuntime } from "../../../src/utils/format";
19
- import { isRecord } from "../../../src/utils/is-record";
20
- import { truncateForDisplay } from "../../shared/display-text";
21
- import { renderProcessTab } from "../../shared/process-tabs";
20
+ } from "../../shared/protocol";
21
+ import {
22
+ renderShortcutHints,
23
+ SHORTCUTS_KEY,
24
+ type ShortcutHint,
25
+ } from "../../shared/shortcut-hints";
26
+ import {
27
+ type ShortcutGroup,
28
+ showShortcutsOverlay,
29
+ } from "../../shared/shortcuts-overlay";
22
30
  import { truncateToWidth } from "../../shared/truncate";
23
31
  import { LineComponent, LinesComponent, RuleComponent } from "../../shared/ui";
24
32
  import { requestProcessList } from "../client";
@@ -82,6 +90,8 @@ export class LogOverlayComponent implements Component {
82
90
  * survive a round-trip. Mirrors the notifyMarkers persistence pattern.
83
91
  */
84
92
  private readonly viewers = new Map<string, LogFileViewer>();
93
+ /** Disposer for the "?" shortcuts overlay, when open. */
94
+ private shortcutsHelp: (() => void) | null = null;
85
95
 
86
96
  constructor(private readonly opts: LogOverlayOptions) {
87
97
  this.configureSearchInput();
@@ -89,7 +99,7 @@ export class LogOverlayComponent implements Component {
89
99
  this.refreshProcesses(opts.initialProcessId);
90
100
  this.disposers.push(
91
101
  opts.events.on(CHANNELS.CHANGED, (payload) => {
92
- if (isChangedPayload(payload)) this.handleProcessesChanged(payload);
102
+ this.handleProcessesChanged(payload as ProcessesChangedPayload);
93
103
  }),
94
104
  );
95
105
  this.disposers.push(
@@ -99,8 +109,9 @@ export class LogOverlayComponent implements Component {
99
109
  );
100
110
  }
101
111
 
102
- private handleNotification(payload: unknown): void {
103
- if (!isLogMatchNotification(payload)) return;
112
+ private handleNotification(rawPayload: unknown): void {
113
+ const payload = rawPayload as ProcessProtocolNotificationPayload;
114
+ if (payload.kind !== "log_match" || !payload.logMatch) return;
104
115
  const mark: NotifyMatchMark = {
105
116
  pattern: payload.logMatch.pattern,
106
117
  line: payload.logMatch.line,
@@ -144,6 +155,31 @@ export class LogOverlayComponent implements Component {
144
155
  return panel.render(width);
145
156
  }
146
157
 
158
+ /**
159
+ * Key dispatch for normal mode, keyed by parsed key id. Close keys and
160
+ * the mode-specific search keys are handled in `handleInput` before the
161
+ * table is consulted.
162
+ */
163
+ private readonly keyActions: Record<string, () => void> = {
164
+ [Key.tab]: () => this.selectRelative(1),
165
+ [Key.shift("tab")]: () => this.selectRelative(-1),
166
+ [Key.down]: () => this.viewer?.scrollBy(-1),
167
+ [Key.up]: () => this.viewer?.scrollBy(1),
168
+ [Key.pageDown]: () => this.viewer?.scrollBy(-this.logRows()),
169
+ [Key.pageUp]: () => this.viewer?.scrollBy(this.logRows()),
170
+ [Key.ctrl("d")]: () => this.viewer?.scrollBy(-this.halfPageRows()),
171
+ [Key.ctrl("u")]: () => this.viewer?.scrollBy(this.halfPageRows()),
172
+ j: () => this.viewer?.scrollBy(-1),
173
+ k: () => this.viewer?.scrollBy(1),
174
+ g: () => this.viewer?.scrollToTop(),
175
+ G: () => this.viewer?.scrollToBottom(),
176
+ s: () => this.viewer?.cycleStreamFilter(),
177
+ f: () => this.viewer?.toggleFollow(),
178
+ w: () => this.viewer?.toggleWrap(),
179
+ "/": () => this.startSearch(),
180
+ [SHORTCUTS_KEY]: () => this.openShortcutsHelp(),
181
+ };
182
+
147
183
  handleInput(data: string): void {
148
184
  if (this.mode === "search-typing") {
149
185
  this.searchInput.handleInput?.(data);
@@ -151,25 +187,27 @@ export class LogOverlayComponent implements Component {
151
187
  return;
152
188
  }
153
189
 
190
+ const key = parseKey(data);
191
+
154
192
  if (this.mode === "search-active") {
155
- if (matchesKey(data, Key.escape)) {
193
+ if (key === "escape") {
156
194
  this.viewer?.clearSearch();
157
195
  this.searchInput.setValue("");
158
196
  this.mode = "normal";
159
197
  this.opts.tui.requestRender();
160
198
  return;
161
199
  }
162
- if (data === "n") {
200
+ if (key === "n") {
163
201
  this.viewer?.nextMatch();
164
202
  this.opts.tui.requestRender();
165
203
  return;
166
204
  }
167
- if (data === "N") {
205
+ if (key === "N") {
168
206
  this.viewer?.previousMatch();
169
207
  this.opts.tui.requestRender();
170
208
  return;
171
209
  }
172
- if (data === "/") {
210
+ if (key === "/") {
173
211
  this.searchInput.setValue(this.viewer?.getSearchInfo()?.query ?? "");
174
212
  this.mode = "search-typing";
175
213
  this.opts.tui.requestRender();
@@ -177,26 +215,12 @@ export class LogOverlayComponent implements Component {
177
215
  }
178
216
  }
179
217
 
180
- if (
181
- matchesKey(data, Key.escape) ||
182
- matchesKey(data, Key.ctrl("c")) ||
183
- data === "q" ||
184
- data === "Q"
185
- ) {
218
+ if (key === "escape" || key === "ctrl+c" || key === "q" || key === "Q") {
186
219
  this.close();
187
220
  return;
188
221
  }
189
222
 
190
- if (matchesKey(data, Key.tab)) this.selectRelative(1);
191
- else if (matchesKey(data, Key.shift("tab"))) this.selectRelative(-1);
192
- else if (matchesKey(data, Key.down) || data === "j")
193
- this.viewer?.scrollBy(-1);
194
- else if (matchesKey(data, Key.up) || data === "k") this.viewer?.scrollBy(1);
195
- else if (data === "g") this.viewer?.scrollToTop();
196
- else if (data === "G") this.viewer?.scrollToBottom();
197
- else if (data === "s") this.viewer?.cycleStreamFilter();
198
- else if (data === "f") this.viewer?.toggleFollow();
199
- else if (data === "/") this.startSearch();
223
+ this.keyActions[key ?? ""]?.();
200
224
 
201
225
  this.opts.tui.requestRender();
202
226
  }
@@ -223,6 +247,8 @@ export class LogOverlayComponent implements Component {
223
247
  dispose(): void {
224
248
  if (this.disposed) return;
225
249
  this.disposed = true;
250
+ this.shortcutsHelp?.();
251
+ this.shortcutsHelp = null;
226
252
  if (this.renderTimer) {
227
253
  clearTimeout(this.renderTimer);
228
254
  this.renderTimer = null;
@@ -318,6 +344,24 @@ export class LogOverlayComponent implements Component {
318
344
  this.opts.onClose();
319
345
  }
320
346
 
347
+ /** Rows scrolled by ctrl+u / ctrl+d (half a viewport). */
348
+ private halfPageRows(): number {
349
+ return Math.max(1, Math.floor(this.logRows() / 2));
350
+ }
351
+
352
+ /**
353
+ * Open the "?" shortcuts overlay on top of this overlay. While open it
354
+ * captures input; closing it restores focus here. Disposed with the
355
+ * overlay so a background close (auto-hide, kill) cannot leave it behind.
356
+ */
357
+ private openShortcutsHelp(): void {
358
+ if (this.shortcutsHelp) return;
359
+ this.shortcutsHelp = showShortcutsOverlay(this.opts.tui, {
360
+ theme: this.opts.theme,
361
+ groups: this.shortcutGroups(),
362
+ });
363
+ }
364
+
321
365
  private refreshProcesses(preferredProcessId?: string): void {
322
366
  this.processes = this.sortProcesses(requestProcessList(this.opts.events));
323
367
  if (this.processes.some((process) => process.status === "running")) {
@@ -604,37 +648,98 @@ export class LogOverlayComponent implements Component {
604
648
 
605
649
  const leftPrefix = this.message ?? statusLeft.join(" ");
606
650
  const prefix = leftPrefix ? `${leftPrefix} ` : "";
607
- const keys = this.renderFooterKeys(
651
+ const keys = renderShortcutHints(
652
+ this.footerHints(),
653
+ this.opts.theme,
608
654
  Math.max(1, width - visibleWidth(prefix)),
609
655
  );
610
656
  return truncateToWidth(`${prefix}${keys}`, width);
611
657
  }
612
658
 
613
- private renderFooterKeys(width: number): string {
614
- const dim = (value: string) => this.opts.theme.fg("dim", value);
615
- const accent = (value: string) => this.opts.theme.fg("accent", value);
616
-
659
+ /** Footer hint list; search mode adds its extra keys up front. */
660
+ private footerHints(): ShortcutHint[] {
661
+ const hints: ShortcutHint[] = [];
617
662
  if (this.mode === "search-active") {
618
- return truncateToWidth(
619
- `${dim("n")} next ${dim("N")} prev ${dim("/")} edit ${dim("esc")} clear ${dim("j/k")} scroll ${dim("q")} close`,
620
- width,
663
+ hints.push(
664
+ { key: "n", label: "next" },
665
+ { key: "N", label: "prev" },
666
+ { key: "/", label: "edit" },
667
+ { key: "esc", label: "clear" },
621
668
  );
622
669
  }
623
-
624
670
  const streamFilter = this.viewer?.getStreamFilter() ?? "both";
625
- const stdout =
626
- streamFilter === "both" || streamFilter === "stdout"
627
- ? accent("stdout")
628
- : dim("stdout");
629
- const stderr =
630
- streamFilter === "both" || streamFilter === "stderr"
631
- ? accent("stderr")
632
- : dim("stderr");
633
-
634
- return truncateToWidth(
635
- `${dim("tab/shift+tab")} switch ${dim("g/G")} top/bot ${dim("j/k")} scroll ${dim("/")} search ${dim("s:")}${stdout}${dim("+")}${stderr} ${dim("f")} follow ${dim("q")} close`,
636
- width,
671
+ const stream = (on: boolean) => (on ? "accent" : "dim");
672
+ const wrapOn = this.viewer?.isWrapEnabled() ?? false;
673
+ hints.push(
674
+ {
675
+ key: "w",
676
+ label: [{ text: "wrap", style: wrapOn ? "accent" : "dim" }],
677
+ },
678
+ { key: "f", label: "follow" },
679
+ { key: "/", label: "search" },
680
+ {
681
+ key: "s",
682
+ label: [
683
+ { text: "stdout", style: stream(streamFilter !== "stderr") },
684
+ { text: "+", style: "dim" },
685
+ { text: "stderr", style: stream(streamFilter !== "stdout") },
686
+ ],
687
+ },
688
+ { key: "j/k", label: "scroll" },
689
+ { key: "pgup/pgdn", label: "page" },
690
+ { key: "^u/^d", label: "half-page" },
691
+ { key: "q", label: "close" },
692
+ { key: "g/G", label: "top/bot" },
693
+ { key: "tab/shift+tab", label: "switch" },
694
+ );
695
+ return hints;
696
+ }
697
+
698
+ /**
699
+ * Groups for the "?" shortcuts overlay. The search group is only present
700
+ * while a search is active; every other key works in both modes.
701
+ */
702
+ private shortcutGroups(): ShortcutGroup[] {
703
+ const groups: ShortcutGroup[] = [];
704
+ if (this.mode === "search-active") {
705
+ groups.push({
706
+ title: "search",
707
+ rows: [
708
+ { keys: "n / N", description: "next / previous match" },
709
+ { keys: "/", description: "edit query" },
710
+ { keys: "esc", description: "clear search" },
711
+ ],
712
+ });
713
+ }
714
+ groups.push(
715
+ {
716
+ title: "scrolling",
717
+ rows: [
718
+ { keys: "j / k", description: "line up / down" },
719
+ { keys: "pgup / pgdn", description: "page up / down" },
720
+ { keys: "ctrl+u / ctrl+d", description: "half page up / down" },
721
+ { keys: "g / G", description: "top / bottom" },
722
+ ],
723
+ },
724
+ {
725
+ title: "view",
726
+ rows: [
727
+ { keys: "w", description: "wrap long lines" },
728
+ { keys: "f", description: "follow newest output" },
729
+ { keys: "s", description: "stream: stdout + stderr" },
730
+ { keys: "/", description: "search" },
731
+ ],
732
+ },
733
+ {
734
+ title: "tabs",
735
+ rows: [{ keys: "tab / shift+tab", description: "switch process" }],
736
+ },
737
+ {
738
+ title: "general",
739
+ rows: [{ keys: "q", description: "close" }],
740
+ },
637
741
  );
742
+ return groups;
638
743
  }
639
744
  }
640
745
 
@@ -655,34 +760,3 @@ function centeredBlock(
655
760
  lines[row] = truncateToWidth(`${" ".repeat(leftPad)}${content}`, width);
656
761
  return lines;
657
762
  }
658
-
659
- function isChangedPayload(
660
- payload: unknown,
661
- ): payload is ProcessesChangedPayload {
662
- return (
663
- isRecord(payload) &&
664
- (payload.reason === "started" ||
665
- payload.reason === "ended" ||
666
- payload.reason === "cleared")
667
- );
668
- }
669
-
670
- function isLogMatchNotification(
671
- payload: unknown,
672
- ): payload is ProcessProtocolNotificationPayload & {
673
- kind: "log_match";
674
- logMatch: NonNullable<ProcessProtocolNotificationPayload["logMatch"]>;
675
- } {
676
- if (!isRecord(payload)) return false;
677
- if (payload.kind !== "log_match") return false;
678
- if (typeof payload.processId !== "string") return false;
679
- if (typeof payload.timestamp !== "number") return false;
680
- const logMatch = payload.logMatch;
681
- if (!isRecord(logMatch)) return false;
682
- if (typeof logMatch.pattern !== "string") return false;
683
- if (typeof logMatch.line !== "string") return false;
684
- if (logMatch.stream !== "stdout" && logMatch.stream !== "stderr")
685
- return false;
686
- if (typeof logMatch.matcherIndex !== "number") return false;
687
- return true;
688
- }
@@ -4,9 +4,7 @@ import {
4
4
  type LogsChunkPayload,
5
5
  type LogsSubscribePayload,
6
6
  type LogsUnsubscribePayload,
7
- } from "../../src/protocol";
8
- import { isRecord } from "../../src/utils/is-record";
9
-
7
+ } from "../shared/protocol";
10
8
  export type ProcessLogLine = { type: "stdout" | "stderr"; text: string };
11
9
 
12
10
  export interface LogsConnection {
@@ -29,8 +27,7 @@ export function connectToProcessLogs(
29
27
 
30
28
  const disposeChunkListener = events.on(CHANNELS.LOGS_CHUNK, (raw) => {
31
29
  if (disposed) return;
32
- if (!isLogsChunkPayload(raw)) return;
33
- const chunk = raw;
30
+ const chunk = raw as LogsChunkPayload;
34
31
  if (chunk.subscriberId !== subscriberId || chunk.processId !== processId)
35
32
  return;
36
33
 
@@ -81,21 +78,3 @@ export function connectToProcessLogs(
81
78
  },
82
79
  };
83
80
  }
84
-
85
- function isLogsChunkPayload(payload: unknown): payload is LogsChunkPayload {
86
- return (
87
- isRecord(payload) &&
88
- typeof payload.subscriberId === "string" &&
89
- typeof payload.processId === "string" &&
90
- Array.isArray(payload.lines) &&
91
- payload.lines.every(isProcessLogLine)
92
- );
93
- }
94
-
95
- function isProcessLogLine(line: unknown): line is ProcessLogLine {
96
- return (
97
- isRecord(line) &&
98
- (line.type === "stdout" || line.type === "stderr") &&
99
- typeof line.text === "string"
100
- );
101
- }