@aliou/pi-processes 0.10.1 → 0.10.3
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.
- package/extensions/process-tabs.ts +2 -2
- package/extensions/processes/commands/kill.ts +12 -6
- package/extensions/processes/commands/overview.ts +2 -1
- package/extensions/processes/components/overview-component.ts +20 -8
- package/extensions/processes/components/overview-panel.ts +2 -1
- package/extensions/processes/handlers/notifications.ts +5 -4
- package/extensions/processes/message-renderer.ts +8 -4
- package/extensions/processes/notifications/service.ts +4 -1
- package/extensions/processes/tools/index.ts +2 -1
- package/extensions/processes/tools/list/render.ts +3 -2
- package/extensions/processes/tools/notify.ts +13 -4
- package/extensions/processes/tools/output/render.ts +4 -3
- package/extensions/processes/tools/schema.ts +10 -8
- package/extensions/processes/tools/update/render.ts +3 -2
- package/extensions/processes/tools/utils.ts +13 -6
- package/extensions/processes-dock/commands/pin.ts +6 -3
- package/extensions/processes-dock/components/log-dock-component.ts +16 -23
- package/extensions/processes-dock/widget/status.ts +7 -7
- package/extensions/processes-logs/commands/logs.ts +2 -1
- package/extensions/processes-logs/completions.ts +5 -2
- package/extensions/processes-logs/components/log-file-viewer.ts +27 -21
- package/extensions/processes-logs/components/log-overlay-component.ts +6 -3
- package/extensions/shared/display-text.ts +200 -0
- package/extensions/shared/log-line.ts +84 -0
- package/extensions/shared/ui.ts +12 -2
- package/package.json +1 -1
- package/skills/pi-processes/SKILL.md +16 -6
- package/src/utils/ansi.ts +3 -1
- package/src/utils/format.ts +0 -8
- package/src/utils/index.ts +1 -6
- /package/extensions/{processes/utils → shared}/truncate.ts +0 -0
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import type { Theme } from "@earendil-works/pi-coding-agent";
|
|
2
2
|
|
|
3
3
|
import type { ProcessInfo } from "../src/types";
|
|
4
|
-
import {
|
|
4
|
+
import { truncateForDisplay } from "./shared/display-text";
|
|
5
5
|
import { MAX_TAB_NAME, statusDot } from "./shared/ui";
|
|
6
6
|
|
|
7
7
|
export { MAX_TAB_NAME, statusDot as renderProcessTabDot };
|
|
@@ -19,7 +19,7 @@ export function renderProcessTab(
|
|
|
19
19
|
theme: Theme,
|
|
20
20
|
): string {
|
|
21
21
|
const dot = statusDot(process, active, theme);
|
|
22
|
-
const label =
|
|
22
|
+
const label = truncateForDisplay(process.name, MAX_TAB_NAME);
|
|
23
23
|
return active
|
|
24
24
|
? theme.bg("selectedBg", ` ${dot} ${theme.fg("accent", label)} `)
|
|
25
25
|
: ` ${dot} ${theme.fg("dim", label)} `;
|
|
@@ -10,7 +10,10 @@ import {
|
|
|
10
10
|
SelectList,
|
|
11
11
|
} from "@earendil-works/pi-tui";
|
|
12
12
|
import { LIVE_STATUSES } from "../../../src/types";
|
|
13
|
-
import {
|
|
13
|
+
import {
|
|
14
|
+
formatProcessSelectionDescription,
|
|
15
|
+
formatProcessSelectionLabel,
|
|
16
|
+
} from "../../shared/ui";
|
|
14
17
|
import { requestKill, requestProcess, requestProcessList } from "../client";
|
|
15
18
|
|
|
16
19
|
/**
|
|
@@ -42,7 +45,10 @@ export function registerKillCommand(pi: ExtensionAPI): void {
|
|
|
42
45
|
return;
|
|
43
46
|
}
|
|
44
47
|
if (!LIVE_STATUSES.has(proc.status)) {
|
|
45
|
-
ctx.ui.notify(
|
|
48
|
+
ctx.ui.notify(
|
|
49
|
+
`${formatProcessSelectionLabel(proc)} is not running.`,
|
|
50
|
+
"warning",
|
|
51
|
+
);
|
|
46
52
|
return;
|
|
47
53
|
}
|
|
48
54
|
id = proc.id;
|
|
@@ -68,10 +74,10 @@ export function registerKillCommand(pi: ExtensionAPI): void {
|
|
|
68
74
|
const timeoutMs = signal === "SIGKILL" ? 200 : 3000;
|
|
69
75
|
const result = await requestKill(events, id, { signal, timeoutMs });
|
|
70
76
|
if (result.ok) {
|
|
71
|
-
ctx.ui.notify(`Killed ${
|
|
77
|
+
ctx.ui.notify(`Killed ${formatProcessSelectionLabel(proc)}.`, "info");
|
|
72
78
|
} else {
|
|
73
79
|
ctx.ui.notify(
|
|
74
|
-
`Failed to kill ${
|
|
80
|
+
`Failed to kill ${formatProcessSelectionLabel(proc)}: ${result.reason}.`,
|
|
75
81
|
"warning",
|
|
76
82
|
);
|
|
77
83
|
}
|
|
@@ -92,7 +98,7 @@ async function pickTarget(
|
|
|
92
98
|
const items: SelectItem[] = requestProcessList(events)
|
|
93
99
|
.filter((process) => LIVE_STATUSES.has(process.status))
|
|
94
100
|
.map((process) => ({
|
|
95
|
-
label:
|
|
101
|
+
label: formatProcessSelectionLabel(process),
|
|
96
102
|
value: process.id,
|
|
97
103
|
description: formatProcessSelectionDescription(process),
|
|
98
104
|
}));
|
|
@@ -145,7 +151,7 @@ function completions(
|
|
|
145
151
|
)
|
|
146
152
|
.map((process) => ({
|
|
147
153
|
value: process.id,
|
|
148
|
-
label:
|
|
154
|
+
label: formatProcessSelectionLabel(process),
|
|
149
155
|
description: formatProcessSelectionDescription(process),
|
|
150
156
|
}));
|
|
151
157
|
return items.length > 0 ? items : null;
|
|
@@ -6,6 +6,7 @@ import type {
|
|
|
6
6
|
} from "@earendil-works/pi-coding-agent";
|
|
7
7
|
import type { ProcessProtocolConfig } from "../../../src/protocol";
|
|
8
8
|
import type { ProcessInfo } from "../../../src/types";
|
|
9
|
+
import { sanitizeForDisplay } from "../../shared/display-text";
|
|
9
10
|
import { requestConfig, requestProcess, requestProcessList } from "../client";
|
|
10
11
|
import { OverviewComponent } from "../components/overview-component";
|
|
11
12
|
|
|
@@ -96,7 +97,7 @@ function formatPlainProcessList(processes: ProcessInfo[]): string {
|
|
|
96
97
|
return processes
|
|
97
98
|
.map(
|
|
98
99
|
(process) =>
|
|
99
|
-
`${process.id}\t${process.name}\t${process.status}\t${process.command}`,
|
|
100
|
+
`${process.id}\t${sanitizeForDisplay(process.name)}\t${process.status}\t${sanitizeForDisplay(process.command)}`,
|
|
100
101
|
)
|
|
101
102
|
.join("\n");
|
|
102
103
|
}
|
|
@@ -6,14 +6,19 @@ import {
|
|
|
6
6
|
Key,
|
|
7
7
|
matchesKey,
|
|
8
8
|
type TUI,
|
|
9
|
-
truncateToWidth,
|
|
10
9
|
visibleWidth,
|
|
11
10
|
} from "@earendil-works/pi-tui";
|
|
12
11
|
import { CHANNELS, type ProcessProtocolConfig } from "../../../src/protocol";
|
|
13
12
|
import { LIVE_STATUSES, type ProcessInfo } from "../../../src/types";
|
|
14
|
-
import { formatRuntime
|
|
13
|
+
import { formatRuntime } from "../../../src/utils/format";
|
|
14
|
+
import {
|
|
15
|
+
sanitizeForDisplay,
|
|
16
|
+
truncateForDisplay,
|
|
17
|
+
} from "../../shared/display-text";
|
|
15
18
|
import { buildDroppedOutputLine, trimToBudget } from "../../shared/line-buffer";
|
|
19
|
+
import { renderLogLine } from "../../shared/log-line";
|
|
16
20
|
import { isOutputChangedPayload } from "../../shared/output-payload";
|
|
21
|
+
import { truncateToWidth } from "../../shared/truncate";
|
|
17
22
|
import { LineComponent, LinesComponent, statusColor } from "../../shared/ui";
|
|
18
23
|
import {
|
|
19
24
|
type ProcessLogLine,
|
|
@@ -573,7 +578,12 @@ export class OverviewComponent implements Component {
|
|
|
573
578
|
width: number,
|
|
574
579
|
): string {
|
|
575
580
|
const t = this.opts.theme;
|
|
576
|
-
const name = truncateToWidth(
|
|
581
|
+
const name = truncateToWidth(
|
|
582
|
+
sanitizeForDisplay(process.name),
|
|
583
|
+
MAX_NAME_WIDTH,
|
|
584
|
+
"",
|
|
585
|
+
true,
|
|
586
|
+
);
|
|
577
587
|
const id = truncateToWidth(process.id, MAX_ID_WIDTH, "", true);
|
|
578
588
|
const status = truncateToWidth(
|
|
579
589
|
formatColoredStatusShort(process, t),
|
|
@@ -596,7 +606,7 @@ export class OverviewComponent implements Component {
|
|
|
596
606
|
const marker = this.pinnedId === process.id ? t.fg("accent", "◆") : " ";
|
|
597
607
|
const left = `${marker} ${name}${sep}${dim(id)}${sep}${status}${sep}${runtime}`;
|
|
598
608
|
const remaining = Math.max(0, width - visibleWidth(left) - 2);
|
|
599
|
-
const command = dim(
|
|
609
|
+
const command = dim(truncateForDisplay(process.command, remaining));
|
|
600
610
|
const line = `${left}${sep}${command}`;
|
|
601
611
|
|
|
602
612
|
if (selected) {
|
|
@@ -614,17 +624,19 @@ export class OverviewComponent implements Component {
|
|
|
614
624
|
const dim = (value: string) => t.fg("dim", value);
|
|
615
625
|
const accent = (value: string) => t.fg("accent", value);
|
|
616
626
|
|
|
617
|
-
const header = `${dim(">")} ${accent(
|
|
627
|
+
const header = `${dim(">")} ${accent(truncateForDisplay(selected.command, Math.max(1, width - 2)))}`;
|
|
618
628
|
const body: string[] = [header];
|
|
619
629
|
const available = this.previewHeight();
|
|
620
630
|
const total = this.previewLines.length;
|
|
621
631
|
const start = this.previewOffset;
|
|
622
632
|
const slice = this.previewLines.slice(start, start + available);
|
|
623
633
|
for (const line of slice) {
|
|
624
|
-
const text =
|
|
625
|
-
line.type === "stderr" ? t.fg("warning", line.text) : line.text;
|
|
626
634
|
body.push(
|
|
627
|
-
|
|
635
|
+
renderLogLine(line, {
|
|
636
|
+
theme: t,
|
|
637
|
+
width,
|
|
638
|
+
prefix: dim(PREVIEW_LOG_PREFIX),
|
|
639
|
+
}),
|
|
628
640
|
);
|
|
629
641
|
}
|
|
630
642
|
while (body.length < available + 1) body.push("");
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import type { Component } from "@earendil-works/pi-tui";
|
|
2
|
-
import {
|
|
2
|
+
import { visibleWidth } from "@earendil-works/pi-tui";
|
|
3
|
+
import { truncateToWidth } from "../../shared/truncate";
|
|
3
4
|
|
|
4
5
|
export interface OverviewPanelOptions {
|
|
5
6
|
title?: string;
|
|
@@ -60,10 +60,11 @@ export function registerNotificationDelivery(
|
|
|
60
60
|
summary: `Suppressed ${count} log-match notifications because output was too fast.`,
|
|
61
61
|
attention: "context",
|
|
62
62
|
};
|
|
63
|
-
sendProcessNotificationMessage(
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
63
|
+
sendProcessNotificationMessage(
|
|
64
|
+
pi,
|
|
65
|
+
details,
|
|
66
|
+
attentionToSendOptions(details.attention),
|
|
67
|
+
);
|
|
67
68
|
};
|
|
68
69
|
|
|
69
70
|
const flushSuppressedSummary = () => {
|
|
@@ -4,10 +4,9 @@ import type {
|
|
|
4
4
|
Theme,
|
|
5
5
|
} from "@earendil-works/pi-coding-agent";
|
|
6
6
|
import { type Component, Container, Text } from "@earendil-works/pi-tui";
|
|
7
|
-
|
|
7
|
+
import { truncateForDisplay } from "../shared/display-text";
|
|
8
8
|
import { MESSAGE_TYPE_PROCESS_NOTIFICATION } from "./constants";
|
|
9
9
|
import type { ProcessNotificationDetails } from "./notifications/types";
|
|
10
|
-
import { truncateToWidth } from "./utils/truncate";
|
|
11
10
|
|
|
12
11
|
export function registerProcessNotificationRenderer(pi: ExtensionAPI): void {
|
|
13
12
|
pi.registerMessageRenderer<ProcessNotificationDetails>(
|
|
@@ -41,10 +40,15 @@ function formatSummary(
|
|
|
41
40
|
theme: Theme,
|
|
42
41
|
): string {
|
|
43
42
|
const prefix = theme.fg("accent", "[process]");
|
|
44
|
-
const name = theme.fg(
|
|
43
|
+
const name = theme.fg(
|
|
44
|
+
"muted",
|
|
45
|
+
`"${truncateForDisplay(details.processName, 60)}"`,
|
|
46
|
+
);
|
|
45
47
|
|
|
46
48
|
if (details.logMatch) {
|
|
47
|
-
|
|
49
|
+
// Matched text is raw process output: sanitize before it reaches the
|
|
50
|
+
// transcript, or an escape sequence in a log line hits the terminal.
|
|
51
|
+
const line = truncateForDisplay(details.logMatch.line, 160);
|
|
48
52
|
return `${prefix} ${name} matched ${details.logMatch.stream}: ${line}`;
|
|
49
53
|
}
|
|
50
54
|
|
|
@@ -25,7 +25,10 @@ const DEFAULT_ATTENTION: Record<
|
|
|
25
25
|
"onSuccess" | "onFailure" | "onKilled",
|
|
26
26
|
Attention
|
|
27
27
|
> = {
|
|
28
|
-
|
|
28
|
+
// Keep in sync with DEFAULT_NOTIFY_CONFIG in tools/notify.ts. Success
|
|
29
|
+
// defaults to a turn because "context" is only seen by an agent that is
|
|
30
|
+
// still streaming when the process ends.
|
|
31
|
+
onSuccess: "turn",
|
|
29
32
|
onFailure: "turn",
|
|
30
33
|
// External kills surface as context by default (see DEFAULT_NOTIFY_CONFIG
|
|
31
34
|
// in tools/notify.ts for rationale). Intentional stops are classified
|
|
@@ -54,7 +54,8 @@ export function registerProcessTool(
|
|
|
54
54
|
"Manage long-running background processes: start, list, stop, write to stdin, update watches, clear finished entries, and inspect recent output. After starting a process, do not wait - notifications bring you back on exit and on log matches.",
|
|
55
55
|
promptGuidelines: [
|
|
56
56
|
"process tool: use process start for long-running commands (dev servers, watchers, builds) instead of shell background patterns like &, nohup, or setsid; give each process a specific name and check process list first when a duplicate would be noisy.",
|
|
57
|
-
"process tool: after process start, do not sleep, poll, or hold your turn. End your turn or move on.
|
|
57
|
+
"process tool: after process start, do not sleep, poll, or hold your turn. End your turn or move on. Exits and notify.logMatches matches bring you back.",
|
|
58
|
+
"process tool: attention turn wakes you when idle; context only reaches you if you are still working; ignore never notifies. Keep turn for anything whose result you need.",
|
|
58
59
|
"process tool: use notify.logMatches to get brought back on readiness or error signals instead of polling process output. If a watch is too noisy, use process update (watches.mode append/replace/remove/clear) to fix it without restarting.",
|
|
59
60
|
"process tool: for the full lifecycle (start, list, output, update, write, stop, clear), notify options, use cases, and noisy-watch handling, read the pi-processes skill.",
|
|
60
61
|
],
|
|
@@ -1,8 +1,9 @@
|
|
|
1
1
|
import type { Theme } from "@earendil-works/pi-coding-agent";
|
|
2
2
|
import { Container, Text, visibleWidth } from "@earendil-works/pi-tui";
|
|
3
3
|
import { formatTimestamp, shortenPath } from "../../../../src/utils";
|
|
4
|
+
import { sanitizeForDisplay } from "../../../shared/display-text";
|
|
5
|
+
import { truncateToWidth } from "../../../shared/truncate";
|
|
4
6
|
import { LinesComponent } from "../../../shared/ui";
|
|
5
|
-
import { truncateToWidth } from "../../utils/truncate";
|
|
6
7
|
import { formatPatternForDisplay, ProcessActionTitle } from "../components";
|
|
7
8
|
import type {
|
|
8
9
|
ProcessesParamsType,
|
|
@@ -75,7 +76,7 @@ export function buildCollapsed(details: ListDetails, theme: Theme): Container {
|
|
|
75
76
|
|
|
76
77
|
for (const process of details.processes.slice(0, 2)) {
|
|
77
78
|
const parts = [
|
|
78
|
-
process.name,
|
|
79
|
+
sanitizeForDisplay(process.name),
|
|
79
80
|
theme.fg("accent", process.id),
|
|
80
81
|
`pid ${process.pid}`,
|
|
81
82
|
formatColoredProcessStatus(process, theme),
|
|
@@ -14,7 +14,13 @@ export const MAX_NOTIFY_LOG_MATCHERS = MAX_LOG_MATCHERS_PER_PROCESS;
|
|
|
14
14
|
export const MAX_NOTIFY_PATTERN_LENGTH = MAX_LOG_MATCH_PATTERN_LENGTH;
|
|
15
15
|
|
|
16
16
|
const DEFAULT_NOTIFY_CONFIG = {
|
|
17
|
-
|
|
17
|
+
// A backgrounded process usually outlives the turn that started it, and
|
|
18
|
+
// "context" only reaches the agent if it happens to still be streaming when
|
|
19
|
+
// the process ends. Builds, tests, and other one-shot commands are started
|
|
20
|
+
// precisely because the agent needs the result, so success defaults to a
|
|
21
|
+
// turn. Long-running servers rarely exit 0, and callers that do not want the
|
|
22
|
+
// interruption can pass onSuccess: "context".
|
|
23
|
+
onSuccess: "turn",
|
|
18
24
|
onFailure: "turn",
|
|
19
25
|
// External kills (outside this manager) surface as context by default so
|
|
20
26
|
// the agent and user learn that a managed process disappeared. Intentional
|
|
@@ -36,11 +42,14 @@ export function normalizeNotifyConfig(input: unknown): NotifyConfig {
|
|
|
36
42
|
|
|
37
43
|
return {
|
|
38
44
|
onSuccess:
|
|
39
|
-
normalizeAttention(input.onSuccess, "notify.onSuccess") ??
|
|
45
|
+
normalizeAttention(input.onSuccess, "notify.onSuccess") ??
|
|
46
|
+
DEFAULT_NOTIFY_CONFIG.onSuccess,
|
|
40
47
|
onFailure:
|
|
41
|
-
normalizeAttention(input.onFailure, "notify.onFailure") ??
|
|
48
|
+
normalizeAttention(input.onFailure, "notify.onFailure") ??
|
|
49
|
+
DEFAULT_NOTIFY_CONFIG.onFailure,
|
|
42
50
|
onKilled:
|
|
43
|
-
normalizeAttention(input.onKilled, "notify.onKilled") ??
|
|
51
|
+
normalizeAttention(input.onKilled, "notify.onKilled") ??
|
|
52
|
+
DEFAULT_NOTIFY_CONFIG.onKilled,
|
|
44
53
|
logMatches: normalizeLogMatches(logMatches),
|
|
45
54
|
};
|
|
46
55
|
}
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import type { Theme } from "@earendil-works/pi-coding-agent";
|
|
2
2
|
import { Container, Spacer, Text } from "@earendil-works/pi-tui";
|
|
3
3
|
|
|
4
|
+
import { sanitizeForDisplay } from "../../../shared/display-text";
|
|
4
5
|
import { ProcessActionHeader } from "../components";
|
|
5
6
|
import type { ProcessesParamsType } from "../schema";
|
|
6
7
|
import { buildField } from "../utils";
|
|
@@ -32,7 +33,7 @@ export function buildExpanded(
|
|
|
32
33
|
if (bodyLines.length > 0) {
|
|
33
34
|
container.addChild(new Spacer(1));
|
|
34
35
|
for (const line of bodyLines) {
|
|
35
|
-
container.addChild(new Text(line, 0, 0));
|
|
36
|
+
container.addChild(new Text(sanitizeForDisplay(line), 0, 0));
|
|
36
37
|
}
|
|
37
38
|
} else {
|
|
38
39
|
container.addChild(new Spacer(1));
|
|
@@ -61,7 +62,7 @@ export function buildCollapsed(
|
|
|
61
62
|
container.addChild(
|
|
62
63
|
new Text(
|
|
63
64
|
[
|
|
64
|
-
details.processName,
|
|
65
|
+
sanitizeForDisplay(details.processName),
|
|
65
66
|
theme.fg("accent", details.id),
|
|
66
67
|
theme.fg(getStatusTone(details), details.processStatus),
|
|
67
68
|
]
|
|
@@ -73,7 +74,7 @@ export function buildCollapsed(
|
|
|
73
74
|
);
|
|
74
75
|
|
|
75
76
|
const bodyLines = extractOutputBody(contentText, details);
|
|
76
|
-
const preview = bodyLines.slice(-2).join("\n");
|
|
77
|
+
const preview = bodyLines.slice(-2).map(sanitizeForDisplay).join("\n");
|
|
77
78
|
|
|
78
79
|
if (preview) {
|
|
79
80
|
container.addChild(new Text(theme.fg("muted", preview), 0, 0));
|
|
@@ -110,28 +110,25 @@ const WatchUpdateItemParams = Type.Object({
|
|
|
110
110
|
),
|
|
111
111
|
on: Type.Optional(
|
|
112
112
|
StringEnum(PROCESS_NOTIFY_ATTENTIONS, {
|
|
113
|
-
description: "
|
|
113
|
+
description: "Attention for this match. Defaults to turn.",
|
|
114
114
|
}),
|
|
115
115
|
),
|
|
116
116
|
});
|
|
117
117
|
|
|
118
|
-
const
|
|
118
|
+
const NotifyProperties = {
|
|
119
119
|
onSuccess: Type.Optional(
|
|
120
120
|
StringEnum(PROCESS_NOTIFY_ATTENTIONS, {
|
|
121
|
-
description:
|
|
122
|
-
"Agent attention when the process exits successfully. Defaults to context.",
|
|
121
|
+
description: "Attention on clean exit. Defaults to turn.",
|
|
123
122
|
}),
|
|
124
123
|
),
|
|
125
124
|
onFailure: Type.Optional(
|
|
126
125
|
StringEnum(PROCESS_NOTIFY_ATTENTIONS, {
|
|
127
|
-
description:
|
|
128
|
-
"Agent attention when the process fails or crashes. Defaults to turn.",
|
|
126
|
+
description: "Attention on failure or crash. Defaults to turn.",
|
|
129
127
|
}),
|
|
130
128
|
),
|
|
131
129
|
onKilled: Type.Optional(
|
|
132
130
|
StringEnum(PROCESS_NOTIFY_ATTENTIONS, {
|
|
133
|
-
description:
|
|
134
|
-
"Agent attention when the process is killed. Defaults to context.",
|
|
131
|
+
description: "Attention on external kill. Defaults to context.",
|
|
135
132
|
}),
|
|
136
133
|
),
|
|
137
134
|
logMatches: Type.Optional(
|
|
@@ -141,6 +138,11 @@ const NotifyParams = Type.Object({
|
|
|
141
138
|
"Log match notifications. Supports at most 20 matchers, with each pattern limited to 500 characters.",
|
|
142
139
|
}),
|
|
143
140
|
),
|
|
141
|
+
};
|
|
142
|
+
|
|
143
|
+
const NotifyParams = Type.Object(NotifyProperties, {
|
|
144
|
+
description:
|
|
145
|
+
"Notify settings. Attention: turn wakes an idle agent, context only reaches an agent still working, ignore never notifies.",
|
|
144
146
|
});
|
|
145
147
|
|
|
146
148
|
export const ProcessesParams = Type.Object({
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import type { Theme } from "@earendil-works/pi-coding-agent";
|
|
2
2
|
import { Container, Text } from "@earendil-works/pi-tui";
|
|
3
|
+
import { sanitizeForDisplay } from "../../../shared/display-text";
|
|
3
4
|
import type { LogMatcherConfig } from "../../notifications/registry";
|
|
4
5
|
import { buildMatcherLine, ProcessActionHeader } from "../components";
|
|
5
6
|
import type { ProcessesParamsType } from "../schema";
|
|
@@ -31,7 +32,7 @@ export function buildExpanded(details: UpdateDetails, theme: Theme): Container {
|
|
|
31
32
|
if (details.renamed && details.previousName && details.process) {
|
|
32
33
|
container.addChild(
|
|
33
34
|
new Text(
|
|
34
|
-
`${theme.fg("muted", "renamed:")} ${details.previousName} -> ${theme.fg("accent", details.process.name)}`,
|
|
35
|
+
`${theme.fg("muted", "renamed:")} ${sanitizeForDisplay(details.previousName)} -> ${theme.fg("accent", sanitizeForDisplay(details.process.name))}`,
|
|
35
36
|
0,
|
|
36
37
|
0,
|
|
37
38
|
),
|
|
@@ -62,7 +63,7 @@ export function buildCollapsed(
|
|
|
62
63
|
container.addChild(
|
|
63
64
|
buildField(
|
|
64
65
|
"renamed",
|
|
65
|
-
`${details.previousName} -> ${details.process.name}`,
|
|
66
|
+
`${sanitizeForDisplay(details.previousName)} -> ${sanitizeForDisplay(details.process.name)}`,
|
|
66
67
|
theme,
|
|
67
68
|
),
|
|
68
69
|
);
|
|
@@ -8,8 +8,11 @@ import {
|
|
|
8
8
|
formatTimestamp,
|
|
9
9
|
shortenPath,
|
|
10
10
|
} from "../../../src/utils";
|
|
11
|
+
import {
|
|
12
|
+
sanitizeForDisplay,
|
|
13
|
+
truncateForDisplay,
|
|
14
|
+
} from "../../shared/display-text";
|
|
11
15
|
import { statusColor } from "../../shared/ui";
|
|
12
|
-
import { truncateToWidth } from "../utils/truncate";
|
|
13
16
|
|
|
14
17
|
export interface RenderOptions {
|
|
15
18
|
expanded?: boolean;
|
|
@@ -33,8 +36,8 @@ export function buildCommandField(
|
|
|
33
36
|
options?: { truncate?: boolean },
|
|
34
37
|
): Text {
|
|
35
38
|
const value = options?.truncate
|
|
36
|
-
?
|
|
37
|
-
: command;
|
|
39
|
+
? truncateForDisplay(command, 80)
|
|
40
|
+
: sanitizeForDisplay(command);
|
|
38
41
|
return buildField("command", theme.fg("accent", `\`${value}\``), theme);
|
|
39
42
|
}
|
|
40
43
|
|
|
@@ -45,7 +48,9 @@ export function buildProcessDetails(
|
|
|
45
48
|
): Container {
|
|
46
49
|
const container = new Container();
|
|
47
50
|
container.addChild(buildField("id", process.id, theme));
|
|
48
|
-
container.addChild(
|
|
51
|
+
container.addChild(
|
|
52
|
+
buildField("name", sanitizeForDisplay(process.name), theme),
|
|
53
|
+
);
|
|
49
54
|
container.addChild(
|
|
50
55
|
buildField("status", formatColoredProcessStatus(process, theme), theme),
|
|
51
56
|
);
|
|
@@ -58,7 +63,9 @@ export function buildProcessDetails(
|
|
|
58
63
|
container.addChild(
|
|
59
64
|
buildField("started", formatTimestamp(process.startTime), theme),
|
|
60
65
|
);
|
|
61
|
-
container.addChild(
|
|
66
|
+
container.addChild(
|
|
67
|
+
buildField("cwd", sanitizeForDisplay(shortenPath(process.cwd)), theme),
|
|
68
|
+
);
|
|
62
69
|
container.addChild(buildCommandField(process.command, theme));
|
|
63
70
|
container.addChild(buildField("stdout", process.stdoutFile, theme));
|
|
64
71
|
container.addChild(buildField("stderr", process.stderrFile, theme));
|
|
@@ -71,7 +78,7 @@ export function buildProcessSummaryRow(
|
|
|
71
78
|
): Text {
|
|
72
79
|
return new Text(
|
|
73
80
|
[
|
|
74
|
-
process.name,
|
|
81
|
+
sanitizeForDisplay(process.name),
|
|
75
82
|
theme.fg("accent", process.id),
|
|
76
83
|
`pid ${process.pid}`,
|
|
77
84
|
formatColoredProcessStatus(process, theme),
|
|
@@ -9,7 +9,10 @@ import {
|
|
|
9
9
|
type SelectItem,
|
|
10
10
|
SelectList,
|
|
11
11
|
} from "@earendil-works/pi-tui";
|
|
12
|
-
import {
|
|
12
|
+
import {
|
|
13
|
+
formatProcessSelectionDescription,
|
|
14
|
+
formatProcessSelectionLabel,
|
|
15
|
+
} from "../../shared/ui";
|
|
13
16
|
import { requestProcess, requestProcessList } from "../client";
|
|
14
17
|
import type { DockController } from "../widget/setup";
|
|
15
18
|
|
|
@@ -83,7 +86,7 @@ async function pickPinTarget(
|
|
|
83
86
|
]
|
|
84
87
|
: []),
|
|
85
88
|
...processes.map((process) => ({
|
|
86
|
-
label:
|
|
89
|
+
label: formatProcessSelectionLabel(process),
|
|
87
90
|
value: process.id,
|
|
88
91
|
description: formatProcessSelectionDescription(
|
|
89
92
|
process,
|
|
@@ -149,7 +152,7 @@ function completions(
|
|
|
149
152
|
)
|
|
150
153
|
.map((process) => ({
|
|
151
154
|
value: process.id,
|
|
152
|
-
label:
|
|
155
|
+
label: formatProcessSelectionLabel(process),
|
|
153
156
|
description: formatProcessSelectionDescription(process),
|
|
154
157
|
}));
|
|
155
158
|
const items = [...clearItems, ...processItems];
|
|
@@ -1,14 +1,12 @@
|
|
|
1
1
|
import { Stack } from "@aliou/pi-utils-ui";
|
|
2
2
|
import type { Theme } from "@earendil-works/pi-coding-agent";
|
|
3
|
-
import {
|
|
4
|
-
type Component,
|
|
5
|
-
truncateToWidth,
|
|
6
|
-
visibleWidth,
|
|
7
|
-
} from "@earendil-works/pi-tui";
|
|
3
|
+
import { type Component, visibleWidth } from "@earendil-works/pi-tui";
|
|
8
4
|
import type { ProcessInfo } from "../../../src/types";
|
|
9
5
|
import { LIVE_STATUSES } from "../../../src/types";
|
|
10
|
-
import { stripAnsi } from "../../../src/utils/ansi";
|
|
11
6
|
import { renderProcessTab } from "../../process-tabs";
|
|
7
|
+
import { sanitizeForDisplay } from "../../shared/display-text";
|
|
8
|
+
import { displayTextOf, renderLogLine } from "../../shared/log-line";
|
|
9
|
+
import { truncateToWidth } from "../../shared/truncate";
|
|
12
10
|
import {
|
|
13
11
|
clampNameColumn,
|
|
14
12
|
LineComponent,
|
|
@@ -278,18 +276,11 @@ function renderAllRunningLogLines(
|
|
|
278
276
|
);
|
|
279
277
|
const nameCol = clampNameColumn(activeProcesses);
|
|
280
278
|
const separator = theme.fg("dim", " │ ");
|
|
281
|
-
const sepLen = visibleWidth(separator);
|
|
282
279
|
|
|
283
280
|
return stream.slice(-height).map(({ processId, line }) => {
|
|
284
281
|
const process = byId.get(processId);
|
|
285
282
|
const label = theme.fg("dim", padName(process?.name ?? processId, nameCol));
|
|
286
|
-
|
|
287
|
-
line,
|
|
288
|
-
snapshot,
|
|
289
|
-
theme,
|
|
290
|
-
Math.max(1, width - nameCol - sepLen),
|
|
291
|
-
);
|
|
292
|
-
return truncateToWidth(`${label}${separator}${text}`, width, "", true);
|
|
283
|
+
return renderLogText(line, snapshot, theme, width, `${label}${separator}`);
|
|
293
284
|
});
|
|
294
285
|
}
|
|
295
286
|
|
|
@@ -311,15 +302,17 @@ function renderLogText(
|
|
|
311
302
|
snapshot: LogDockSnapshot,
|
|
312
303
|
theme: Theme,
|
|
313
304
|
width: number,
|
|
305
|
+
prefix = "",
|
|
314
306
|
): string {
|
|
315
|
-
const
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
307
|
+
const notified =
|
|
308
|
+
snapshot.notifyLines.has(line.text) ||
|
|
309
|
+
snapshot.notifyLines.has(displayTextOf(line));
|
|
310
|
+
return renderLogLine(line, {
|
|
311
|
+
theme,
|
|
312
|
+
width,
|
|
313
|
+
prefix,
|
|
314
|
+
emphasis: notified ? "notify" : "none",
|
|
315
|
+
});
|
|
323
316
|
}
|
|
324
317
|
|
|
325
318
|
function renderProcessToken(
|
|
@@ -370,6 +363,6 @@ function centerLine(content: string, width: number, height: number): string[] {
|
|
|
370
363
|
}
|
|
371
364
|
|
|
372
365
|
function padName(value: string, width: number): string {
|
|
373
|
-
const name = truncateToWidth(value, width, "", true);
|
|
366
|
+
const name = truncateToWidth(sanitizeForDisplay(value), width, "", true);
|
|
374
367
|
return `${name}${" ".repeat(Math.max(0, width - visibleWidth(name)))}`;
|
|
375
368
|
}
|
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
import type { Theme } from "@earendil-works/pi-coding-agent";
|
|
2
|
-
import {
|
|
3
|
-
|
|
2
|
+
import { visibleWidth } from "@earendil-works/pi-tui";
|
|
4
3
|
import { LIVE_STATUSES, type ProcessInfo } from "../../../src/types";
|
|
4
|
+
import { truncateForDisplay } from "../../shared/display-text";
|
|
5
|
+
import { truncateToWidth } from "../../shared/truncate";
|
|
5
6
|
import { statusColor, statusDot } from "../../shared/ui";
|
|
6
7
|
|
|
7
8
|
const MAX_PROCESS_NAME = 20;
|
|
@@ -12,11 +13,10 @@ const DEFAULT_MAX_WIDTH = 200;
|
|
|
12
13
|
* and the name always agree.
|
|
13
14
|
*/
|
|
14
15
|
function formatProcessName(process: ProcessInfo, theme: Theme): string {
|
|
15
|
-
|
|
16
|
-
process
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
return theme.fg(statusColor(process), trimmed);
|
|
16
|
+
return theme.fg(
|
|
17
|
+
statusColor(process),
|
|
18
|
+
truncateForDisplay(process.name, MAX_PROCESS_NAME),
|
|
19
|
+
);
|
|
20
20
|
}
|
|
21
21
|
|
|
22
22
|
/**
|
|
@@ -6,6 +6,7 @@ import type {
|
|
|
6
6
|
} from "@earendil-works/pi-coding-agent";
|
|
7
7
|
import type { ProcessProtocolConfig } from "../../../src/protocol";
|
|
8
8
|
import type { ProcessInfo } from "../../../src/types";
|
|
9
|
+
import { sanitizeForDisplay } from "../../shared/display-text";
|
|
9
10
|
import { requestConfig, requestProcess, requestProcessList } from "../client";
|
|
10
11
|
import { allProcessCompletions } from "../completions";
|
|
11
12
|
import { LogOverlayComponent } from "../components/log-overlay-component";
|
|
@@ -102,7 +103,7 @@ function formatPlainProcessList(processes: ProcessInfo[]): string {
|
|
|
102
103
|
return processes
|
|
103
104
|
.map(
|
|
104
105
|
(process) =>
|
|
105
|
-
`${process.id}\t${process.name}\t${process.status}\t${process.command}`,
|
|
106
|
+
`${process.id}\t${sanitizeForDisplay(process.name)}\t${process.status}\t${sanitizeForDisplay(process.command)}`,
|
|
106
107
|
)
|
|
107
108
|
.join("\n");
|
|
108
109
|
}
|
|
@@ -1,6 +1,9 @@
|
|
|
1
1
|
import type { EventBus } from "@earendil-works/pi-coding-agent";
|
|
2
2
|
import type { AutocompleteItem } from "@earendil-works/pi-tui";
|
|
3
|
-
import {
|
|
3
|
+
import {
|
|
4
|
+
formatProcessSelectionDescription,
|
|
5
|
+
formatProcessSelectionLabel,
|
|
6
|
+
} from "../shared/ui";
|
|
4
7
|
import { requestProcessList } from "./client";
|
|
5
8
|
|
|
6
9
|
export function allProcessCompletions(
|
|
@@ -23,7 +26,7 @@ function buildCompletions(
|
|
|
23
26
|
)
|
|
24
27
|
.map((process) => ({
|
|
25
28
|
value: process.id,
|
|
26
|
-
label:
|
|
29
|
+
label: formatProcessSelectionLabel(process),
|
|
27
30
|
description: formatProcessSelectionDescription(process),
|
|
28
31
|
}));
|
|
29
32
|
|
|
@@ -1,6 +1,9 @@
|
|
|
1
1
|
import type { Theme } from "@earendil-works/pi-coding-agent";
|
|
2
|
-
import {
|
|
2
|
+
import { visibleWidth } from "@earendil-works/pi-tui";
|
|
3
|
+
import { sanitizeForDisplay } from "../../shared/display-text";
|
|
3
4
|
import { trimToBudget } from "../../shared/line-buffer";
|
|
5
|
+
import { type LogLineEmphasis, renderLogLine } from "../../shared/log-line";
|
|
6
|
+
import { truncateToWidth } from "../../shared/truncate";
|
|
4
7
|
import type { ProcessLogLine } from "../logs-client";
|
|
5
8
|
|
|
6
9
|
export type StreamFilter = "both" | "stdout" | "stderr";
|
|
@@ -29,7 +32,7 @@ export class LogFileViewer {
|
|
|
29
32
|
) {
|
|
30
33
|
this.follow = options.followEnabled;
|
|
31
34
|
this.lines = trimToBudget(
|
|
32
|
-
initialLines,
|
|
35
|
+
initialLines.map(sanitizeLine),
|
|
33
36
|
options.maxBufferLines,
|
|
34
37
|
options.maxBufferBytes ?? Number.MAX_SAFE_INTEGER,
|
|
35
38
|
);
|
|
@@ -38,7 +41,7 @@ export class LogFileViewer {
|
|
|
38
41
|
|
|
39
42
|
appendLines(lines: ProcessLogLine[]): void {
|
|
40
43
|
if (lines.length === 0) return;
|
|
41
|
-
this.lines.push(...lines);
|
|
44
|
+
this.lines.push(...lines.map(sanitizeLine));
|
|
42
45
|
this.lines = trimToBudget(
|
|
43
46
|
this.lines,
|
|
44
47
|
this.options.maxBufferLines,
|
|
@@ -119,7 +122,9 @@ export class LogFileViewer {
|
|
|
119
122
|
* priority (search current match > search match > notify match > stream).
|
|
120
123
|
*/
|
|
121
124
|
addNotifyMatch(match: { line: string }): void {
|
|
122
|
-
|
|
125
|
+
// Stored sanitized so it can be compared against sanitized buffer lines.
|
|
126
|
+
const line = sanitizeForDisplay(match.line);
|
|
127
|
+
if (line) this.notifyLines.add(line);
|
|
123
128
|
}
|
|
124
129
|
|
|
125
130
|
clearNotifyMatches(): void {
|
|
@@ -182,23 +187,15 @@ export class LogFileViewer {
|
|
|
182
187
|
|
|
183
188
|
const rendered = visible.slice(start, end).map((line, index) => {
|
|
184
189
|
const visibleIndex = start + index;
|
|
185
|
-
const
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
}
|
|
195
|
-
if (this.notifyLines.has(line.text)) {
|
|
196
|
-
return truncateToWidth(this.theme.underline(text), width);
|
|
197
|
-
}
|
|
198
|
-
if (line.type === "stderr") {
|
|
199
|
-
return truncateToWidth(this.theme.fg("warning", text), width);
|
|
200
|
-
}
|
|
201
|
-
return truncateToWidth(text, width);
|
|
190
|
+
const emphasis: LogLineEmphasis =
|
|
191
|
+
visibleIndex === currentMatchIndex
|
|
192
|
+
? "search-current"
|
|
193
|
+
: matchSet.has(visibleIndex)
|
|
194
|
+
? "search"
|
|
195
|
+
: this.notifyLines.has(line.text)
|
|
196
|
+
? "notify"
|
|
197
|
+
: "none";
|
|
198
|
+
return renderLogLine(line, { theme: this.theme, width, emphasis });
|
|
202
199
|
});
|
|
203
200
|
|
|
204
201
|
while (rendered.length < height) rendered.unshift("");
|
|
@@ -272,3 +269,12 @@ export class LogFileViewer {
|
|
|
272
269
|
this.centerTarget = index;
|
|
273
270
|
}
|
|
274
271
|
}
|
|
272
|
+
|
|
273
|
+
/**
|
|
274
|
+
* Log lines are untrusted terminal output. Sanitize on ingest so search,
|
|
275
|
+
* notify-match comparison, and rendering all see the same safe text.
|
|
276
|
+
*/
|
|
277
|
+
function sanitizeLine(line: ProcessLogLine): ProcessLogLine {
|
|
278
|
+
const text = sanitizeForDisplay(line.text);
|
|
279
|
+
return text === line.text ? line : { ...line, text };
|
|
280
|
+
}
|
|
@@ -6,7 +6,6 @@ import {
|
|
|
6
6
|
Key,
|
|
7
7
|
matchesKey,
|
|
8
8
|
type TUI,
|
|
9
|
-
truncateToWidth,
|
|
10
9
|
visibleWidth,
|
|
11
10
|
} from "@earendil-works/pi-tui";
|
|
12
11
|
import {
|
|
@@ -16,9 +15,11 @@ import {
|
|
|
16
15
|
type ProcessProtocolNotificationPayload,
|
|
17
16
|
} from "../../../src/protocol";
|
|
18
17
|
import { LIVE_STATUSES, type ProcessInfo } from "../../../src/types";
|
|
19
|
-
import { formatRuntime
|
|
18
|
+
import { formatRuntime } from "../../../src/utils/format";
|
|
20
19
|
import { isRecord } from "../../../src/utils/is-record";
|
|
21
20
|
import { renderProcessTab } from "../../process-tabs";
|
|
21
|
+
import { truncateForDisplay } from "../../shared/display-text";
|
|
22
|
+
import { truncateToWidth } from "../../shared/truncate";
|
|
22
23
|
import { LineComponent, LinesComponent, RuleComponent } from "../../shared/ui";
|
|
23
24
|
import { requestProcessList } from "../client";
|
|
24
25
|
import {
|
|
@@ -559,7 +560,9 @@ export class LogOverlayComponent implements Component {
|
|
|
559
560
|
4,
|
|
560
561
|
width - visibleWidth(leftPrefix) - durationWidth - 1,
|
|
561
562
|
);
|
|
562
|
-
const command = dim(
|
|
563
|
+
const command = dim(
|
|
564
|
+
truncateForDisplay(process.command, availableCommandWidth),
|
|
565
|
+
);
|
|
563
566
|
const left = `${leftPrefix}${command}`;
|
|
564
567
|
const gap = Math.max(1, width - visibleWidth(left) - durationWidth);
|
|
565
568
|
return truncateToWidth(`${left}${" ".repeat(gap)}${duration}`, width);
|
|
@@ -0,0 +1,200 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Make untrusted process output safe to render in the TUI.
|
|
3
|
+
*
|
|
4
|
+
* This module lives outside pi-agnostic `src/` because tab expansion needs the
|
|
5
|
+
* Pi TUI width measurement to stay in sync with what the renderer computes.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { visibleWidth } from "@earendil-works/pi-tui";
|
|
9
|
+
|
|
10
|
+
import { truncateToWidth } from "./truncate";
|
|
11
|
+
|
|
12
|
+
const ESC = String.fromCodePoint(0x001b);
|
|
13
|
+
const BEL = String.fromCodePoint(0x0007);
|
|
14
|
+
const ST = String.fromCodePoint(0x009c);
|
|
15
|
+
const RESET = `${ESC}[0m`;
|
|
16
|
+
|
|
17
|
+
// Control characters a single display row must never contain. Tabs are handled
|
|
18
|
+
// separately; newlines are dropped because they would shift the whole frame.
|
|
19
|
+
// C1 controls are included: a raw \u009b is an alias for CSI on some terminals.
|
|
20
|
+
// biome-ignore lint/suspicious/noControlCharactersInRegex: this regex intentionally targets terminal control characters.
|
|
21
|
+
const DISPLAY_CONTROL_CHARS = /[\u0000-\u0008\u000a-\u001f\u007f-\u009f]/gu;
|
|
22
|
+
|
|
23
|
+
// SGR parameters we consider safe to keep: colors and attributes only.
|
|
24
|
+
const SGR_PARAMS = /^[0-9;:]*$/u;
|
|
25
|
+
|
|
26
|
+
// Terminals advance tabs to the next 8-column stop, but the TUI measures a tab
|
|
27
|
+
// as a fixed width. Expanding here keeps measured width and drawn width equal.
|
|
28
|
+
const TAB_WIDTH = 8;
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Sanitize one line of terminal output for display while keeping its colors.
|
|
32
|
+
*
|
|
33
|
+
* Keeps SGR sequences (`ESC[...m`) and drops everything else: cursor
|
|
34
|
+
* movement, erase, scroll regions, alternate screen switches, OSC/DCS/APC
|
|
35
|
+
* strings (terminated or not), charset designators, lone escapes, newlines,
|
|
36
|
+
* and C0/C1 control characters. Tabs are expanded to spaces. Those are the
|
|
37
|
+
* inputs that corrupt the screen, because they either act on the real
|
|
38
|
+
* terminal or make width measurement disagree with what the terminal draws.
|
|
39
|
+
*
|
|
40
|
+
* A trailing reset is appended when any SGR survives so colors cannot bleed
|
|
41
|
+
* into the rest of the frame.
|
|
42
|
+
*/
|
|
43
|
+
export function sanitizeForDisplay(text: string): string {
|
|
44
|
+
if (!text.includes(ESC)) return cleanPlainText(text, 0).text;
|
|
45
|
+
|
|
46
|
+
let out = "";
|
|
47
|
+
let cursor = 0;
|
|
48
|
+
let column = 0;
|
|
49
|
+
let keptSgr = false;
|
|
50
|
+
|
|
51
|
+
while (cursor < text.length) {
|
|
52
|
+
const escapeAt = text.indexOf(ESC, cursor);
|
|
53
|
+
if (escapeAt === -1) {
|
|
54
|
+
out += cleanPlainText(text.slice(cursor), column).text;
|
|
55
|
+
break;
|
|
56
|
+
}
|
|
57
|
+
const chunk = cleanPlainText(text.slice(cursor, escapeAt), column);
|
|
58
|
+
out += chunk.text;
|
|
59
|
+
column = chunk.column;
|
|
60
|
+
|
|
61
|
+
const sequence = readEscapeSequence(text, escapeAt);
|
|
62
|
+
if (sequence.isSgr) {
|
|
63
|
+
out += text.slice(escapeAt, sequence.end);
|
|
64
|
+
keptSgr = true;
|
|
65
|
+
}
|
|
66
|
+
cursor = sequence.end;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
if (!keptSgr || out.endsWith(RESET)) return out;
|
|
70
|
+
return `${out}${RESET}`;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* Fit a single-line label, such as a command or a process name, into `width`
|
|
75
|
+
* terminal cells. Sanitizes first, then truncates by display width so wide
|
|
76
|
+
* characters and emoji cannot over-run the row or get cut mid-grapheme.
|
|
77
|
+
*/
|
|
78
|
+
export function truncateForDisplay(text: string, width: number): string {
|
|
79
|
+
return closeSgr(truncateToWidth(sanitizeForDisplay(text), width, "…"));
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* Re-close colors after truncation. `sanitizeForDisplay` ends a colored string
|
|
84
|
+
* with a reset, but truncating can cut that reset off and let the color bleed
|
|
85
|
+
* into the rest of the frame.
|
|
86
|
+
*/
|
|
87
|
+
export function closeSgr(text: string): string {
|
|
88
|
+
if (!text.includes(ESC) || text.trimEnd().endsWith(RESET)) return text;
|
|
89
|
+
return `${text}${RESET}`;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* Drop control characters and expand tabs, starting from `column` so tab stops
|
|
94
|
+
* line up across the chunks of a line split by escape sequences. Columns are
|
|
95
|
+
* measured in terminal cells, so wide and zero-width characters land right.
|
|
96
|
+
*/
|
|
97
|
+
function cleanPlainText(
|
|
98
|
+
text: string,
|
|
99
|
+
column: number,
|
|
100
|
+
): { text: string; column: number } {
|
|
101
|
+
const clean = text.replace(DISPLAY_CONTROL_CHARS, "");
|
|
102
|
+
if (!clean.includes("\t")) {
|
|
103
|
+
return { text: clean, column: column + visibleWidth(clean) };
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
const parts = clean.split("\t");
|
|
107
|
+
let out = parts[0] ?? "";
|
|
108
|
+
let col = column + visibleWidth(out);
|
|
109
|
+
for (const part of parts.slice(1)) {
|
|
110
|
+
const spaces = TAB_WIDTH - (col % TAB_WIDTH);
|
|
111
|
+
out += " ".repeat(spaces) + part;
|
|
112
|
+
col += spaces + visibleWidth(part);
|
|
113
|
+
}
|
|
114
|
+
return { text: out, column: col };
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/**
|
|
118
|
+
* Find the end of the escape sequence starting at `start` (which must point at
|
|
119
|
+
* an ESC) and report whether it is a plain SGR sequence.
|
|
120
|
+
*
|
|
121
|
+
* Unterminated sequences swallow the rest of the string: a terminal would do
|
|
122
|
+
* the same, so dropping the tail is what keeps the screen intact.
|
|
123
|
+
*/
|
|
124
|
+
function readEscapeSequence(
|
|
125
|
+
text: string,
|
|
126
|
+
start: number,
|
|
127
|
+
): { end: number; isSgr: boolean } {
|
|
128
|
+
const next = text[start + 1];
|
|
129
|
+
if (next === undefined) return { end: text.length, isSgr: false };
|
|
130
|
+
|
|
131
|
+
if (next === "[") return readControlSequence(text, start);
|
|
132
|
+
|
|
133
|
+
// OSC (]) and APC (_): standard terminator is ST, but BEL is widely used
|
|
134
|
+
// for both in practice (pi's own cursor marker included), so accept it.
|
|
135
|
+
if (next === "]" || next === "_") {
|
|
136
|
+
return { end: findStringTerminator(text, start + 2, true), isSgr: false };
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
// DCS (P), SOS (X), PM (^): ST only. A BEL inside a sixel payload is data.
|
|
140
|
+
if (next === "P" || next === "X" || next === "^") {
|
|
141
|
+
return { end: findStringTerminator(text, start + 2, false), isSgr: false };
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
// Sequences with one intermediate byte: charset designators (ESC ( B),
|
|
145
|
+
// ESC % G, ESC # 8, and friends.
|
|
146
|
+
if (next >= "\u0020" && next <= "\u002f") {
|
|
147
|
+
return { end: Math.min(start + 3, text.length), isSgr: false };
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
// Everything else is a two-byte escape: ESC c (reset), ESC 7/8, ESC =, ...
|
|
151
|
+
return { end: start + 2, isSgr: false };
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
/** Index just past the terminator of a string sequence, else end of input. */
|
|
155
|
+
function findStringTerminator(
|
|
156
|
+
text: string,
|
|
157
|
+
from: number,
|
|
158
|
+
allowBel: boolean,
|
|
159
|
+
): number {
|
|
160
|
+
for (let index = from; index < text.length; index++) {
|
|
161
|
+
if (allowBel && text[index] === BEL) return index + 1;
|
|
162
|
+
if (text[index] === ST) return index + 1;
|
|
163
|
+
if (text[index] === ESC && text[index + 1] === "\\") return index + 2;
|
|
164
|
+
}
|
|
165
|
+
return text.length;
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
function readControlSequence(
|
|
169
|
+
text: string,
|
|
170
|
+
start: number,
|
|
171
|
+
): { end: number; isSgr: boolean } {
|
|
172
|
+
let index = start + 2;
|
|
173
|
+
const paramStart = index;
|
|
174
|
+
while (
|
|
175
|
+
index < text.length &&
|
|
176
|
+
text[index] >= "\u0030" &&
|
|
177
|
+
text[index] <= "\u003f"
|
|
178
|
+
) {
|
|
179
|
+
index++;
|
|
180
|
+
}
|
|
181
|
+
const paramEnd = index;
|
|
182
|
+
while (
|
|
183
|
+
index < text.length &&
|
|
184
|
+
text[index] >= "\u0020" &&
|
|
185
|
+
text[index] <= "\u002f"
|
|
186
|
+
) {
|
|
187
|
+
index++;
|
|
188
|
+
}
|
|
189
|
+
const hasIntermediates = index > paramEnd;
|
|
190
|
+
const final = text[index];
|
|
191
|
+
if (final === undefined || final < "\u0040" || final > "\u007e") {
|
|
192
|
+
return { end: text.length, isSgr: false };
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
const isSgr =
|
|
196
|
+
final === "m" &&
|
|
197
|
+
!hasIntermediates &&
|
|
198
|
+
SGR_PARAMS.test(text.slice(paramStart, paramEnd));
|
|
199
|
+
return { end: index + 1, isSgr };
|
|
200
|
+
}
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* One renderer for a single line of process output, shared by the `/ps`
|
|
3
|
+
* preview, the `/ps:logs` overlay, and the dock.
|
|
4
|
+
*
|
|
5
|
+
* Every log view sanitizes untrusted output, truncates it to the row width,
|
|
6
|
+
* and tones it by stream and match state. Keeping that in one place is what
|
|
7
|
+
* stops the three views from drifting apart again.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import type { Theme } from "@earendil-works/pi-coding-agent";
|
|
11
|
+
import { visibleWidth } from "@earendil-works/pi-tui";
|
|
12
|
+
|
|
13
|
+
import { closeSgr, sanitizeForDisplay } from "./display-text";
|
|
14
|
+
import { truncateToWidth } from "./truncate";
|
|
15
|
+
|
|
16
|
+
export interface DisplayLogLine {
|
|
17
|
+
type: "stdout" | "stderr";
|
|
18
|
+
text: string;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Why a line stands out, in priority order. `search-current` is the match the
|
|
23
|
+
* user is sitting on, `search` is any other hit, `notify` is a watch match.
|
|
24
|
+
*/
|
|
25
|
+
export type LogLineEmphasis = "none" | "notify" | "search" | "search-current";
|
|
26
|
+
|
|
27
|
+
export interface RenderLogLineOptions {
|
|
28
|
+
theme: Theme;
|
|
29
|
+
/** Total row width, including `prefix`. */
|
|
30
|
+
width: number;
|
|
31
|
+
emphasis?: LogLineEmphasis;
|
|
32
|
+
/** Already-styled row prefix, such as the dock's process label. */
|
|
33
|
+
prefix?: string;
|
|
34
|
+
/** Drop colors from the log text itself. Match and stream tones still apply. */
|
|
35
|
+
plain?: boolean;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export function renderLogLine(
|
|
39
|
+
line: DisplayLogLine,
|
|
40
|
+
options: RenderLogLineOptions,
|
|
41
|
+
): string {
|
|
42
|
+
const {
|
|
43
|
+
theme,
|
|
44
|
+
width,
|
|
45
|
+
emphasis = "none",
|
|
46
|
+
prefix = "",
|
|
47
|
+
plain = false,
|
|
48
|
+
} = options;
|
|
49
|
+
if (width <= 0) return "";
|
|
50
|
+
|
|
51
|
+
const prefixWidth = visibleWidth(prefix);
|
|
52
|
+
const textWidth = Math.max(1, width - prefixWidth);
|
|
53
|
+
const safe = sanitizeForDisplay(line.text);
|
|
54
|
+
const text = closeSgr(
|
|
55
|
+
truncateToWidth(plain ? stripSgr(safe) : safe, textWidth, "", true),
|
|
56
|
+
);
|
|
57
|
+
|
|
58
|
+
return `${prefix}${toneLogText(text, line.type, emphasis, theme)}`;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/** Text of a log line as the views display it, for match comparisons. */
|
|
62
|
+
export function displayTextOf(line: DisplayLogLine): string {
|
|
63
|
+
return sanitizeForDisplay(line.text);
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
function toneLogText(
|
|
67
|
+
text: string,
|
|
68
|
+
type: DisplayLogLine["type"],
|
|
69
|
+
emphasis: LogLineEmphasis,
|
|
70
|
+
theme: Theme,
|
|
71
|
+
): string {
|
|
72
|
+
if (emphasis === "search-current") return theme.bold(theme.inverse(text));
|
|
73
|
+
if (emphasis === "search") return theme.fg("warning", text);
|
|
74
|
+
if (emphasis === "notify") return theme.underline(text);
|
|
75
|
+
if (type === "stderr") return theme.fg("warning", text);
|
|
76
|
+
return text;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
const SGR = new RegExp(`${String.fromCodePoint(0x001b)}\\[[0-9;:]*m`, "gu");
|
|
80
|
+
|
|
81
|
+
/** Drop the SGR sequences `sanitizeForDisplay` kept. */
|
|
82
|
+
function stripSgr(text: string): string {
|
|
83
|
+
return text.replace(SGR, "");
|
|
84
|
+
}
|
package/extensions/shared/ui.ts
CHANGED
|
@@ -13,6 +13,7 @@ import { type Component, visibleWidth } from "@earendil-works/pi-tui";
|
|
|
13
13
|
|
|
14
14
|
import { LIVE_STATUSES, type ProcessInfo } from "../../src/types";
|
|
15
15
|
import { formatStatus } from "../../src/utils/format";
|
|
16
|
+
import { sanitizeForDisplay } from "./display-text";
|
|
16
17
|
|
|
17
18
|
// ---------------------------------------------------------------------------
|
|
18
19
|
// Render components
|
|
@@ -72,7 +73,7 @@ export function clampNameColumn(
|
|
|
72
73
|
min: number = MIN_NAME_COLUMN,
|
|
73
74
|
): number {
|
|
74
75
|
const longest = processes.reduce(
|
|
75
|
-
(acc, p) => Math.max(acc, visibleWidth(p.name)),
|
|
76
|
+
(acc, p) => Math.max(acc, visibleWidth(sanitizeForDisplay(p.name))),
|
|
76
77
|
0,
|
|
77
78
|
);
|
|
78
79
|
return Math.min(Math.max(longest, min), max);
|
|
@@ -132,6 +133,15 @@ export function statusDot(
|
|
|
132
133
|
return theme.fg("dim", "■");
|
|
133
134
|
}
|
|
134
135
|
|
|
136
|
+
/**
|
|
137
|
+
* Build the `label` string for process picker items and autocomplete
|
|
138
|
+
* completions: `"api (proc_1)"`. Names are untrusted display text, so they are
|
|
139
|
+
* sanitized here instead of at every call site.
|
|
140
|
+
*/
|
|
141
|
+
export function formatProcessSelectionLabel(process: ProcessInfo): string {
|
|
142
|
+
return `${sanitizeForDisplay(process.name)} (${process.id})`;
|
|
143
|
+
}
|
|
144
|
+
|
|
135
145
|
/**
|
|
136
146
|
* Build the `description` string for process picker items and autocomplete
|
|
137
147
|
* completions: `"running — pnpm dev"`. Shared so `/ps:kill`, `/ps:logs`,
|
|
@@ -141,6 +151,6 @@ export function formatProcessSelectionDescription(
|
|
|
141
151
|
process: ProcessInfo,
|
|
142
152
|
suffix = "",
|
|
143
153
|
): string {
|
|
144
|
-
const base = `${formatStatus(process)} — ${process.command}`;
|
|
154
|
+
const base = `${formatStatus(process)} — ${sanitizeForDisplay(process.command)}`;
|
|
145
155
|
return suffix ? `${base}${suffix}` : base;
|
|
146
156
|
}
|
package/package.json
CHANGED
|
@@ -15,9 +15,11 @@ A started process runs in the background and the manager brings you back when so
|
|
|
15
15
|
2. End your turn or move on to other work. Do not call `process output` in a loop waiting for "ready".
|
|
16
16
|
3. The process notifies you when:
|
|
17
17
|
- a `logMatches` pattern hits (readiness, error, progress),
|
|
18
|
-
- the process exits successfully (`onSuccess`, default `
|
|
18
|
+
- the process exits successfully (`onSuccess`, default `turn`),
|
|
19
19
|
- the process fails or crashes (`onFailure`, default `turn`),
|
|
20
|
-
- the process is killed
|
|
20
|
+
- the process is killed externally (`onKilled`, default `context`, which does not wake an idle agent).
|
|
21
|
+
|
|
22
|
+
Stopping a process yourself never notifies.
|
|
21
23
|
4. When a watch is too noisy or wrong, fix it with `process update` — do not restart the process just to change watches.
|
|
22
24
|
5. `process stop` obsolete live processes and `process clear` finished entries when they are no longer useful.
|
|
23
25
|
|
|
@@ -50,6 +52,8 @@ Good:
|
|
|
50
52
|
}
|
|
51
53
|
```
|
|
52
54
|
|
|
55
|
+
`onSuccess: "context"` here because a dev server exiting cleanly needs no reaction. Keep the default `turn` for builds, tests, and other one-shot commands whose result you need.
|
|
56
|
+
|
|
53
57
|
Optional `cwd` sets the working directory for the spawned command. Omit it to inherit the agent's current working directory.
|
|
54
58
|
|
|
55
59
|
Empty `logMatches` patterns (literal or regex) are rejected at start and update time. Use `mode: "regex"` only when literal matching is not enough, scope by `stream` to cut noise, and use `repeat: true` when a matcher should fire more than once.
|
|
@@ -214,9 +218,9 @@ Good:
|
|
|
214
218
|
|
|
215
219
|
Exit attention:
|
|
216
220
|
|
|
217
|
-
- `notify.onSuccess` —
|
|
218
|
-
- `notify.onFailure` —
|
|
219
|
-
- `notify.onKilled` —
|
|
221
|
+
- `notify.onSuccess` — clean exit. Defaults to `turn`.
|
|
222
|
+
- `notify.onFailure` — failure or crash. Defaults to `turn`.
|
|
223
|
+
- `notify.onKilled` — killed from outside the tool. Defaults to `context`. Stopping a process yourself never notifies.
|
|
220
224
|
|
|
221
225
|
Log match watches (`notify.logMatches`, up to 20, each pattern up to 500 chars):
|
|
222
226
|
|
|
@@ -226,7 +230,13 @@ Log match watches (`notify.logMatches`, up to 20, each pattern up to 500 chars):
|
|
|
226
230
|
- `repeat` — `false` (default) fires once; `true` fires on every match.
|
|
227
231
|
- `on` — `turn`, `context`, or `ignore`. Overrides the default attention for that watch. Defaults to `turn`.
|
|
228
232
|
|
|
229
|
-
|
|
233
|
+
Attention levels:
|
|
234
|
+
|
|
235
|
+
- `turn` — starts an agent turn. Reaches you even when you are idle.
|
|
236
|
+
- `context` — recorded in the transcript, no turn. It reaches you only if you are still working when the event fires; an idle agent is not woken and sees it on the next user message.
|
|
237
|
+
- `ignore` — recorded, never notifies.
|
|
238
|
+
|
|
239
|
+
Use `context` only when nothing needs to happen in response.
|
|
230
240
|
|
|
231
241
|
## Use cases
|
|
232
242
|
|
package/src/utils/ansi.ts
CHANGED
|
@@ -22,8 +22,10 @@ const ANSI_REPLACEMENTS: RegExp[] = [
|
|
|
22
22
|
// Strip C0 terminal control characters that can corrupt TUI layout when
|
|
23
23
|
// rendered back into pi, such as carriage return and backspace. Keep tabs and
|
|
24
24
|
// newlines because logs use them as printable whitespace/line breaks.
|
|
25
|
+
// C1 controls are stripped too: a raw \u009b is an alias for CSI on some
|
|
26
|
+
// terminals, so leaving it in would reopen the escape-sequence hole.
|
|
25
27
|
// biome-ignore lint/suspicious/noControlCharactersInRegex: this regex intentionally targets terminal control characters.
|
|
26
|
-
const TERMINAL_CONTROL_CHARS = /[\u0000-\u0008\u000b-\u001f\u007f]/gu;
|
|
28
|
+
const TERMINAL_CONTROL_CHARS = /[\u0000-\u0008\u000b-\u001f\u007f-\u009f]/gu;
|
|
27
29
|
|
|
28
30
|
/** Check if a string contains ANSI escape codes. */
|
|
29
31
|
export function hasAnsi(str: string): boolean {
|
package/src/utils/format.ts
CHANGED
|
@@ -49,14 +49,6 @@ export function formatStatus(proc: {
|
|
|
49
49
|
}
|
|
50
50
|
}
|
|
51
51
|
|
|
52
|
-
/**
|
|
53
|
-
* Truncate a command string to a maximum length.
|
|
54
|
-
*/
|
|
55
|
-
export function truncateCmd(cmd: string, max = 40): string {
|
|
56
|
-
if (cmd.length <= max) return cmd;
|
|
57
|
-
return `${cmd.slice(0, max - 3)}...`;
|
|
58
|
-
}
|
|
59
|
-
|
|
60
52
|
/**
|
|
61
53
|
* Format a timestamp as an ISO string or "-" if null.
|
|
62
54
|
*/
|
package/src/utils/index.ts
CHANGED
|
@@ -1,11 +1,6 @@
|
|
|
1
1
|
export { hasAnsi, stripAnsi } from "./ansi";
|
|
2
2
|
export { resolveShellExecutable, spawnCommand } from "./command-executor";
|
|
3
|
-
export {
|
|
4
|
-
formatRuntime,
|
|
5
|
-
formatStatus,
|
|
6
|
-
formatTimestamp,
|
|
7
|
-
truncateCmd,
|
|
8
|
-
} from "./format";
|
|
3
|
+
export { formatRuntime, formatStatus, formatTimestamp } from "./format";
|
|
9
4
|
export type { LineMatchMode } from "./match-line";
|
|
10
5
|
export { compileLineMatcher } from "./match-line";
|
|
11
6
|
export { isProcessGroupAlive, killProcessGroup } from "./process-group";
|
|
File without changes
|