@aliou/pi-processes 0.5.0 → 0.6.1
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/README.md +82 -13
- package/package.json +2 -2
- package/src/commands/clear/command.ts +14 -0
- package/src/commands/clear/index.ts +1 -0
- package/src/commands/completions.ts +38 -0
- package/src/commands/dock/command.ts +29 -0
- package/src/commands/dock/index.ts +1 -0
- package/src/commands/index.ts +26 -327
- package/src/commands/kill/command.ts +69 -0
- package/src/commands/kill/index.ts +1 -0
- package/src/commands/logs/command.ts +47 -0
- package/src/commands/logs/index.ts +1 -0
- package/src/commands/pick-process.ts +34 -0
- package/src/commands/pin/command.ts +33 -0
- package/src/commands/pin/index.ts +1 -0
- package/src/commands/processes/command.ts +39 -0
- package/src/commands/processes/index.ts +1 -0
- package/src/commands/settings/apply-setting-change.ts +72 -0
- package/src/commands/settings/build-sections.ts +160 -0
- package/src/commands/settings/command.ts +20 -0
- package/src/commands/settings/index.ts +1 -0
- package/src/components/log-dock-component.ts +238 -0
- package/src/components/log-file-viewer.ts +317 -0
- package/src/components/log-overlay-component.ts +555 -0
- package/src/components/processes-component.ts +0 -1
- package/src/config.ts +28 -1
- package/src/constants/index.ts +1 -0
- package/src/constants/types.ts +7 -1
- package/src/hooks/index.ts +5 -3
- package/src/hooks/process-end.ts +3 -30
- package/src/hooks/widget/index.ts +2 -0
- package/src/hooks/widget/setup.ts +164 -0
- package/src/hooks/{widget.ts → widget/status-widget.ts} +7 -74
- package/src/hooks/widget/types.ts +20 -0
- package/src/index.ts +8 -3
- package/src/manager.ts +61 -9
- package/src/tools/actions/index.ts +5 -0
- package/src/tools/actions/write.ts +87 -0
- package/src/tools/index.ts +82 -14
- package/src/utils/command-executor.ts +1 -1
- package/src/utils/keybindings.ts +71 -0
- package/src/commands/settings-command.ts +0 -179
- package/src/components/log-stream-component.ts +0 -149
- package/src/test/test-exit-crash.sh +0 -19
- package/src/test/test-exit-failure.sh +0 -17
- package/src/test/test-exit-success.sh +0 -16
- package/src/test/test-output.sh +0 -28
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
import type { ExtensionAPI } from "@mariozechner/pi-coding-agent";
|
|
2
|
+
import { LogOverlayComponent } from "../../components/log-overlay-component";
|
|
3
|
+
import type { ProcessManager } from "../../manager";
|
|
4
|
+
import { allProcessCompletions } from "../completions";
|
|
5
|
+
|
|
6
|
+
export function registerPsLogsCommand(
|
|
7
|
+
pi: ExtensionAPI,
|
|
8
|
+
manager: ProcessManager,
|
|
9
|
+
): void {
|
|
10
|
+
pi.registerCommand("ps:logs", {
|
|
11
|
+
description:
|
|
12
|
+
"Open log viewer for a process (search, scroll, stream filter)",
|
|
13
|
+
getArgumentCompletions: allProcessCompletions(manager),
|
|
14
|
+
handler: async (args, ctx) => {
|
|
15
|
+
if (!ctx.hasUI) return;
|
|
16
|
+
|
|
17
|
+
const arg = args.trim();
|
|
18
|
+
let processId: string | undefined;
|
|
19
|
+
|
|
20
|
+
if (arg) {
|
|
21
|
+
const proc = manager.find(arg);
|
|
22
|
+
if (!proc) return;
|
|
23
|
+
processId = proc.id;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
await ctx.ui.custom<null>(
|
|
27
|
+
(_tui, theme, _kb, done) => {
|
|
28
|
+
return new LogOverlayComponent({
|
|
29
|
+
tui: _tui,
|
|
30
|
+
theme,
|
|
31
|
+
manager,
|
|
32
|
+
initialProcessId: processId,
|
|
33
|
+
done: () => done(null),
|
|
34
|
+
});
|
|
35
|
+
},
|
|
36
|
+
{
|
|
37
|
+
overlay: true,
|
|
38
|
+
overlayOptions: {
|
|
39
|
+
width: "90%",
|
|
40
|
+
maxHeight: "80%",
|
|
41
|
+
anchor: "center",
|
|
42
|
+
},
|
|
43
|
+
},
|
|
44
|
+
);
|
|
45
|
+
},
|
|
46
|
+
});
|
|
47
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export { registerPsLogsCommand } from "./command";
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import type { ExtensionCommandContext } from "@mariozechner/pi-coding-agent";
|
|
2
|
+
import { ProcessPickerComponent } from "../components/process-picker-component";
|
|
3
|
+
import type { ProcessInfo } from "../constants";
|
|
4
|
+
import type { ProcessManager } from "../manager";
|
|
5
|
+
|
|
6
|
+
export async function pickProcess(
|
|
7
|
+
ctx: ExtensionCommandContext,
|
|
8
|
+
manager: ProcessManager,
|
|
9
|
+
title: string,
|
|
10
|
+
filter?: (proc: ProcessInfo) => boolean,
|
|
11
|
+
): Promise<string | undefined> {
|
|
12
|
+
if (!ctx.hasUI) {
|
|
13
|
+
return undefined;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
const result = await ctx.ui.custom<string | null>((tui, theme, _kb, done) => {
|
|
17
|
+
return new ProcessPickerComponent(
|
|
18
|
+
tui,
|
|
19
|
+
theme,
|
|
20
|
+
(processId?: string) => {
|
|
21
|
+
done(processId ?? null);
|
|
22
|
+
},
|
|
23
|
+
manager,
|
|
24
|
+
title,
|
|
25
|
+
filter,
|
|
26
|
+
);
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
if (result === undefined || result === null) {
|
|
30
|
+
return undefined;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
return result;
|
|
34
|
+
}
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import type { ExtensionAPI } from "@mariozechner/pi-coding-agent";
|
|
2
|
+
import type { DockActions } from "../../hooks/widget";
|
|
3
|
+
import type { ProcessManager } from "../../manager";
|
|
4
|
+
import { allProcessCompletions } from "../completions";
|
|
5
|
+
import { pickProcess } from "../pick-process";
|
|
6
|
+
|
|
7
|
+
export function registerPsPinCommand(
|
|
8
|
+
pi: ExtensionAPI,
|
|
9
|
+
manager: ProcessManager,
|
|
10
|
+
dockActions: DockActions,
|
|
11
|
+
): void {
|
|
12
|
+
pi.registerCommand("ps:pin", {
|
|
13
|
+
description: "Pin the dock to a specific process",
|
|
14
|
+
getArgumentCompletions: allProcessCompletions(manager),
|
|
15
|
+
handler: async (args, ctx) => {
|
|
16
|
+
const arg = args.trim();
|
|
17
|
+
let processId: string | undefined;
|
|
18
|
+
|
|
19
|
+
if (arg) {
|
|
20
|
+
const proc = manager.find(arg);
|
|
21
|
+
if (!proc) {
|
|
22
|
+
return;
|
|
23
|
+
}
|
|
24
|
+
processId = proc.id;
|
|
25
|
+
} else {
|
|
26
|
+
processId = await pickProcess(ctx, manager, "Select process to pin");
|
|
27
|
+
if (!processId) return;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
dockActions.setFocus(processId);
|
|
31
|
+
},
|
|
32
|
+
});
|
|
33
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export { registerPsPinCommand } from "./command";
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import type { ExtensionAPI } from "@mariozechner/pi-coding-agent";
|
|
2
|
+
import { ProcessesComponent } from "../../components/processes-component";
|
|
3
|
+
import type { DockActions } from "../../hooks/widget";
|
|
4
|
+
import type { ProcessManager } from "../../manager";
|
|
5
|
+
|
|
6
|
+
export function registerPsCommand(
|
|
7
|
+
pi: ExtensionAPI,
|
|
8
|
+
manager: ProcessManager,
|
|
9
|
+
dockActions: DockActions,
|
|
10
|
+
): void {
|
|
11
|
+
pi.registerCommand("ps", {
|
|
12
|
+
description: "View and manage background processes",
|
|
13
|
+
handler: async (_args, ctx) => {
|
|
14
|
+
if (!ctx.hasUI) {
|
|
15
|
+
return;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
const result = await ctx.ui.custom<string | null>(
|
|
19
|
+
(tui, theme, _keybindings, done) => {
|
|
20
|
+
return new ProcessesComponent(
|
|
21
|
+
tui,
|
|
22
|
+
theme,
|
|
23
|
+
(processId?: string) => {
|
|
24
|
+
if (processId) {
|
|
25
|
+
dockActions.setFocus(processId);
|
|
26
|
+
}
|
|
27
|
+
done(processId ?? null);
|
|
28
|
+
},
|
|
29
|
+
manager,
|
|
30
|
+
);
|
|
31
|
+
},
|
|
32
|
+
);
|
|
33
|
+
|
|
34
|
+
if (result === undefined) {
|
|
35
|
+
return;
|
|
36
|
+
}
|
|
37
|
+
},
|
|
38
|
+
});
|
|
39
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export { registerPsCommand } from "./command";
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
import type { ProcessesConfig } from "../../config";
|
|
2
|
+
|
|
3
|
+
export function applySettingChange(
|
|
4
|
+
id: string,
|
|
5
|
+
newValue: string,
|
|
6
|
+
config: ProcessesConfig,
|
|
7
|
+
): ProcessesConfig | null {
|
|
8
|
+
const updated = structuredClone(config);
|
|
9
|
+
|
|
10
|
+
if (id === "interception.blockBackgroundCommands") {
|
|
11
|
+
if (!updated.interception) updated.interception = {};
|
|
12
|
+
updated.interception.blockBackgroundCommands = newValue === "on";
|
|
13
|
+
return updated;
|
|
14
|
+
}
|
|
15
|
+
if (id === "widget.showStatusWidget") {
|
|
16
|
+
if (!updated.widget) updated.widget = {};
|
|
17
|
+
updated.widget.showStatusWidget = newValue === "on";
|
|
18
|
+
return updated;
|
|
19
|
+
}
|
|
20
|
+
if (id === "widget.dockDefaultState") {
|
|
21
|
+
if (!updated.widget) updated.widget = {};
|
|
22
|
+
updated.widget.dockDefaultState =
|
|
23
|
+
newValue === "hidden" ? "hidden" : "collapsed";
|
|
24
|
+
return updated;
|
|
25
|
+
}
|
|
26
|
+
if (id === "widget.dockHeight") {
|
|
27
|
+
if (!updated.widget) updated.widget = {};
|
|
28
|
+
updated.widget.dockHeight = Number.parseInt(newValue, 10);
|
|
29
|
+
return updated;
|
|
30
|
+
}
|
|
31
|
+
if (id === "follow.enabledByDefault") {
|
|
32
|
+
if (!updated.follow) updated.follow = {};
|
|
33
|
+
updated.follow.enabledByDefault = newValue === "on";
|
|
34
|
+
return updated;
|
|
35
|
+
}
|
|
36
|
+
if (id === "follow.autoHideOnFinish") {
|
|
37
|
+
if (!updated.follow) updated.follow = {};
|
|
38
|
+
updated.follow.autoHideOnFinish = newValue === "on";
|
|
39
|
+
return updated;
|
|
40
|
+
}
|
|
41
|
+
if (id === "execution.shellPath") {
|
|
42
|
+
if (!updated.execution) updated.execution = {};
|
|
43
|
+
updated.execution.shellPath = newValue === "auto" ? undefined : newValue;
|
|
44
|
+
return updated;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
const num = Number.parseInt(newValue, 10);
|
|
48
|
+
if (Number.isNaN(num)) return null;
|
|
49
|
+
|
|
50
|
+
switch (id) {
|
|
51
|
+
case "processList.maxVisibleProcesses":
|
|
52
|
+
if (!updated.processList) updated.processList = {};
|
|
53
|
+
updated.processList.maxVisibleProcesses = num;
|
|
54
|
+
break;
|
|
55
|
+
case "processList.maxPreviewLines":
|
|
56
|
+
if (!updated.processList) updated.processList = {};
|
|
57
|
+
updated.processList.maxPreviewLines = num;
|
|
58
|
+
break;
|
|
59
|
+
case "output.defaultTailLines":
|
|
60
|
+
if (!updated.output) updated.output = {};
|
|
61
|
+
updated.output.defaultTailLines = num;
|
|
62
|
+
break;
|
|
63
|
+
case "output.maxOutputLines":
|
|
64
|
+
if (!updated.output) updated.output = {};
|
|
65
|
+
updated.output.maxOutputLines = num;
|
|
66
|
+
break;
|
|
67
|
+
default:
|
|
68
|
+
return null;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
return updated;
|
|
72
|
+
}
|
|
@@ -0,0 +1,160 @@
|
|
|
1
|
+
import type { SettingsSection } from "@aliou/pi-utils-settings";
|
|
2
|
+
import type { ProcessesConfig, ResolvedProcessesConfig } from "../../config";
|
|
3
|
+
|
|
4
|
+
export function buildSettingsSections(
|
|
5
|
+
tabConfig: ProcessesConfig | null,
|
|
6
|
+
resolved: ResolvedProcessesConfig,
|
|
7
|
+
): SettingsSection[] {
|
|
8
|
+
return [
|
|
9
|
+
{
|
|
10
|
+
label: "Process List",
|
|
11
|
+
items: [
|
|
12
|
+
{
|
|
13
|
+
id: "processList.maxVisibleProcesses",
|
|
14
|
+
label: "Max visible processes",
|
|
15
|
+
description:
|
|
16
|
+
"Maximum processes shown in the /processes list before scrolling",
|
|
17
|
+
currentValue: String(
|
|
18
|
+
tabConfig?.processList?.maxVisibleProcesses ??
|
|
19
|
+
resolved.processList.maxVisibleProcesses,
|
|
20
|
+
),
|
|
21
|
+
values: ["4", "6", "8", "12", "16"],
|
|
22
|
+
},
|
|
23
|
+
{
|
|
24
|
+
id: "processList.maxPreviewLines",
|
|
25
|
+
label: "Max preview lines",
|
|
26
|
+
description: "Log preview lines shown below the selected process",
|
|
27
|
+
currentValue: String(
|
|
28
|
+
tabConfig?.processList?.maxPreviewLines ??
|
|
29
|
+
resolved.processList.maxPreviewLines,
|
|
30
|
+
),
|
|
31
|
+
values: ["6", "8", "12", "16", "24"],
|
|
32
|
+
},
|
|
33
|
+
],
|
|
34
|
+
},
|
|
35
|
+
{
|
|
36
|
+
label: "Output Limits",
|
|
37
|
+
items: [
|
|
38
|
+
{
|
|
39
|
+
id: "output.defaultTailLines",
|
|
40
|
+
label: "Default tail lines",
|
|
41
|
+
description: "Number of tail lines returned to the agent by default",
|
|
42
|
+
currentValue: String(
|
|
43
|
+
tabConfig?.output?.defaultTailLines ??
|
|
44
|
+
resolved.output.defaultTailLines,
|
|
45
|
+
),
|
|
46
|
+
values: ["50", "100", "200", "500"],
|
|
47
|
+
},
|
|
48
|
+
{
|
|
49
|
+
id: "output.maxOutputLines",
|
|
50
|
+
label: "Max output lines",
|
|
51
|
+
description: "Hard cap on output lines returned to the agent",
|
|
52
|
+
currentValue: String(
|
|
53
|
+
tabConfig?.output?.maxOutputLines ?? resolved.output.maxOutputLines,
|
|
54
|
+
),
|
|
55
|
+
values: ["100", "200", "500", "1000"],
|
|
56
|
+
},
|
|
57
|
+
],
|
|
58
|
+
},
|
|
59
|
+
{
|
|
60
|
+
label: "Execution",
|
|
61
|
+
items: [
|
|
62
|
+
{
|
|
63
|
+
id: "execution.shellPath",
|
|
64
|
+
label: "Shell path",
|
|
65
|
+
description: "Absolute shell path override used to execute commands",
|
|
66
|
+
currentValue:
|
|
67
|
+
tabConfig?.execution?.shellPath ??
|
|
68
|
+
resolved.execution.shellPath ??
|
|
69
|
+
"auto",
|
|
70
|
+
values: [
|
|
71
|
+
"auto",
|
|
72
|
+
"/run/current-system/sw/bin/bash",
|
|
73
|
+
"/bin/bash",
|
|
74
|
+
"/usr/bin/bash",
|
|
75
|
+
"/usr/local/bin/bash",
|
|
76
|
+
],
|
|
77
|
+
},
|
|
78
|
+
],
|
|
79
|
+
},
|
|
80
|
+
{
|
|
81
|
+
label: "Interception",
|
|
82
|
+
items: [
|
|
83
|
+
{
|
|
84
|
+
id: "interception.blockBackgroundCommands",
|
|
85
|
+
label: "Block background commands",
|
|
86
|
+
description:
|
|
87
|
+
"Block bash background commands (&, nohup, disown, setsid) and guide the model to use the process tool",
|
|
88
|
+
currentValue:
|
|
89
|
+
(tabConfig?.interception?.blockBackgroundCommands ??
|
|
90
|
+
resolved.interception.blockBackgroundCommands)
|
|
91
|
+
? "on"
|
|
92
|
+
: "off",
|
|
93
|
+
values: ["on", "off"],
|
|
94
|
+
},
|
|
95
|
+
],
|
|
96
|
+
},
|
|
97
|
+
{
|
|
98
|
+
label: "Widget",
|
|
99
|
+
items: [
|
|
100
|
+
{
|
|
101
|
+
id: "widget.showStatusWidget",
|
|
102
|
+
label: "Show status widget",
|
|
103
|
+
description: "Show process status widget below the editor",
|
|
104
|
+
currentValue:
|
|
105
|
+
(tabConfig?.widget?.showStatusWidget ??
|
|
106
|
+
resolved.widget.showStatusWidget)
|
|
107
|
+
? "on"
|
|
108
|
+
: "off",
|
|
109
|
+
values: ["on", "off"],
|
|
110
|
+
},
|
|
111
|
+
{
|
|
112
|
+
id: "widget.dockDefaultState",
|
|
113
|
+
label: "Dock default state",
|
|
114
|
+
description:
|
|
115
|
+
"Default visibility state of the log dock when follow mode is on",
|
|
116
|
+
currentValue:
|
|
117
|
+
tabConfig?.widget?.dockDefaultState ??
|
|
118
|
+
resolved.widget.dockDefaultState,
|
|
119
|
+
values: ["hidden", "collapsed"],
|
|
120
|
+
},
|
|
121
|
+
{
|
|
122
|
+
id: "widget.dockHeight",
|
|
123
|
+
label: "Dock height",
|
|
124
|
+
description: "Height of the log dock in lines when open",
|
|
125
|
+
currentValue: String(
|
|
126
|
+
tabConfig?.widget?.dockHeight ?? resolved.widget.dockHeight,
|
|
127
|
+
),
|
|
128
|
+
values: ["8", "10", "12", "16", "20"],
|
|
129
|
+
},
|
|
130
|
+
],
|
|
131
|
+
},
|
|
132
|
+
{
|
|
133
|
+
label: "Follow Mode",
|
|
134
|
+
items: [
|
|
135
|
+
{
|
|
136
|
+
id: "follow.enabledByDefault",
|
|
137
|
+
label: "Enable by default",
|
|
138
|
+
description: "Automatically show logs when a process starts",
|
|
139
|
+
currentValue:
|
|
140
|
+
(tabConfig?.follow?.enabledByDefault ??
|
|
141
|
+
resolved.follow.enabledByDefault)
|
|
142
|
+
? "on"
|
|
143
|
+
: "off",
|
|
144
|
+
values: ["on", "off"],
|
|
145
|
+
},
|
|
146
|
+
{
|
|
147
|
+
id: "follow.autoHideOnFinish",
|
|
148
|
+
label: "Auto-hide on finish",
|
|
149
|
+
description: "Hide dock when all processes finish",
|
|
150
|
+
currentValue:
|
|
151
|
+
(tabConfig?.follow?.autoHideOnFinish ??
|
|
152
|
+
resolved.follow.autoHideOnFinish)
|
|
153
|
+
? "on"
|
|
154
|
+
: "off",
|
|
155
|
+
values: ["on", "off"],
|
|
156
|
+
},
|
|
157
|
+
],
|
|
158
|
+
},
|
|
159
|
+
];
|
|
160
|
+
}
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import { registerSettingsCommand } from "@aliou/pi-utils-settings";
|
|
2
|
+
import type { ExtensionAPI } from "@mariozechner/pi-coding-agent";
|
|
3
|
+
import type { ProcessesConfig, ResolvedProcessesConfig } from "../../config";
|
|
4
|
+
import { configLoader } from "../../config";
|
|
5
|
+
import { applySettingChange } from "./apply-setting-change";
|
|
6
|
+
import { buildSettingsSections } from "./build-sections";
|
|
7
|
+
|
|
8
|
+
export function registerProcessesSettings(
|
|
9
|
+
pi: ExtensionAPI,
|
|
10
|
+
onSave?: () => void,
|
|
11
|
+
): void {
|
|
12
|
+
registerSettingsCommand<ProcessesConfig, ResolvedProcessesConfig>(pi, {
|
|
13
|
+
commandName: "ps:settings",
|
|
14
|
+
title: "Processes Settings",
|
|
15
|
+
configStore: configLoader,
|
|
16
|
+
buildSections: buildSettingsSections,
|
|
17
|
+
onSettingChange: applySettingChange,
|
|
18
|
+
onSave,
|
|
19
|
+
});
|
|
20
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export { registerProcessesSettings } from "./command";
|
|
@@ -0,0 +1,238 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Log Dock Component - shows process logs in the bottom dock.
|
|
3
|
+
*
|
|
4
|
+
* Collapsed view: one-line summary (running procs) + last log line.
|
|
5
|
+
* Open view: LogFileViewer for the focused process (or first running), follow mode on.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import {
|
|
9
|
+
createPanelPadder,
|
|
10
|
+
renderPanelRule,
|
|
11
|
+
renderPanelTitleLine,
|
|
12
|
+
} from "@aliou/pi-utils-ui";
|
|
13
|
+
import type { Theme, ThemeColor } from "@mariozechner/pi-coding-agent";
|
|
14
|
+
import type { Component } from "@mariozechner/pi-tui";
|
|
15
|
+
import { truncateToWidth, visibleWidth } from "@mariozechner/pi-tui";
|
|
16
|
+
import { LIVE_STATUSES } from "../constants";
|
|
17
|
+
import type { ProcessManager } from "../manager";
|
|
18
|
+
import { LogFileViewer } from "./log-file-viewer";
|
|
19
|
+
|
|
20
|
+
const POLL_INTERVAL_MS = 500;
|
|
21
|
+
|
|
22
|
+
const PROCESS_COLORS: ThemeColor[] = [
|
|
23
|
+
"accent",
|
|
24
|
+
"warning",
|
|
25
|
+
"success",
|
|
26
|
+
"error",
|
|
27
|
+
"accent",
|
|
28
|
+
"dim",
|
|
29
|
+
"accent",
|
|
30
|
+
"warning",
|
|
31
|
+
];
|
|
32
|
+
|
|
33
|
+
interface LogDockOptions {
|
|
34
|
+
manager: ProcessManager;
|
|
35
|
+
theme: Theme;
|
|
36
|
+
tui: { requestRender: () => void };
|
|
37
|
+
mode: "collapsed" | "open";
|
|
38
|
+
focusedProcessId: string | null;
|
|
39
|
+
dockHeight?: number;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export class LogDockComponent implements Component {
|
|
43
|
+
private manager: ProcessManager;
|
|
44
|
+
private theme: Theme;
|
|
45
|
+
private tui: { requestRender: () => void };
|
|
46
|
+
private dockHeight: number;
|
|
47
|
+
private mode: "collapsed" | "open";
|
|
48
|
+
private focusedProcessId: string | null;
|
|
49
|
+
|
|
50
|
+
private timer: ReturnType<typeof setInterval> | null = null;
|
|
51
|
+
private unsubscribeManager: (() => void) | null = null;
|
|
52
|
+
|
|
53
|
+
/** One viewer per process, lazily created, follow:true. */
|
|
54
|
+
private viewers: Map<string, LogFileViewer> = new Map();
|
|
55
|
+
|
|
56
|
+
private processColors: Map<string, ThemeColor> = new Map();
|
|
57
|
+
private colorCounter = 0;
|
|
58
|
+
|
|
59
|
+
constructor(options: LogDockOptions) {
|
|
60
|
+
this.manager = options.manager;
|
|
61
|
+
this.theme = options.theme;
|
|
62
|
+
this.tui = options.tui;
|
|
63
|
+
this.dockHeight = options.dockHeight ?? 12;
|
|
64
|
+
this.mode = options.mode;
|
|
65
|
+
this.focusedProcessId = options.focusedProcessId;
|
|
66
|
+
|
|
67
|
+
this.timer = setInterval(() => {
|
|
68
|
+
this.tui.requestRender();
|
|
69
|
+
}, POLL_INTERVAL_MS);
|
|
70
|
+
|
|
71
|
+
this.unsubscribeManager = this.manager.onEvent(() => {
|
|
72
|
+
this.tui.requestRender();
|
|
73
|
+
});
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
update(opts: {
|
|
77
|
+
mode: "collapsed" | "open";
|
|
78
|
+
focusedProcessId: string | null;
|
|
79
|
+
dockHeight: number;
|
|
80
|
+
}): void {
|
|
81
|
+
this.mode = opts.mode;
|
|
82
|
+
this.focusedProcessId = opts.focusedProcessId;
|
|
83
|
+
this.dockHeight = opts.dockHeight;
|
|
84
|
+
this.tui.requestRender();
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
handleInput(_data: string): boolean {
|
|
88
|
+
return false;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
invalidate(): void {
|
|
92
|
+
// No local cache; always renders fresh.
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
private getProcessColor(processId: string): ThemeColor {
|
|
96
|
+
const existing = this.processColors.get(processId);
|
|
97
|
+
if (existing) return existing;
|
|
98
|
+
const color = PROCESS_COLORS[this.colorCounter % PROCESS_COLORS.length];
|
|
99
|
+
this.colorCounter++;
|
|
100
|
+
this.processColors.set(processId, color);
|
|
101
|
+
return color;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
private getViewer(processId: string, combinedFile: string): LogFileViewer {
|
|
105
|
+
let viewer = this.viewers.get(processId);
|
|
106
|
+
if (!viewer) {
|
|
107
|
+
viewer = new LogFileViewer({
|
|
108
|
+
filePath: combinedFile,
|
|
109
|
+
format: "combined",
|
|
110
|
+
theme: this.theme,
|
|
111
|
+
follow: true,
|
|
112
|
+
});
|
|
113
|
+
this.viewers.set(processId, viewer);
|
|
114
|
+
}
|
|
115
|
+
return viewer;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
render(width: number): string[] {
|
|
119
|
+
if (this.mode === "collapsed") return this.renderCollapsed(width);
|
|
120
|
+
return this.renderOpen(width);
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
private renderCollapsed(width: number): string[] {
|
|
124
|
+
const theme = this.theme;
|
|
125
|
+
const dim = (s: string) => theme.fg("dim", s);
|
|
126
|
+
const fg = (color: ThemeColor, s: string) => theme.fg(color, s);
|
|
127
|
+
|
|
128
|
+
const processes = this.manager.list();
|
|
129
|
+
const innerWidth = width - 2;
|
|
130
|
+
const padLine = (content: string) => {
|
|
131
|
+
const w = visibleWidth(content);
|
|
132
|
+
const line =
|
|
133
|
+
w > innerWidth ? truncateToWidth(content, innerWidth) : content;
|
|
134
|
+
return ` ${line}${" ".repeat(Math.max(0, width - 1 - visibleWidth(line)))}`;
|
|
135
|
+
};
|
|
136
|
+
|
|
137
|
+
if (processes.length === 0) {
|
|
138
|
+
return [renderPanelRule(width, theme), padLine(dim("No processes"))];
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
const running = processes.filter((p) => LIVE_STATUSES.has(p.status));
|
|
142
|
+
const finished = processes.filter((p) => !LIVE_STATUSES.has(p.status));
|
|
143
|
+
|
|
144
|
+
const parts: string[] = [];
|
|
145
|
+
for (const proc of running) {
|
|
146
|
+
const color = this.getProcessColor(proc.id);
|
|
147
|
+
parts.push(`${fg(color, "●")} ${proc.name}`);
|
|
148
|
+
}
|
|
149
|
+
if (finished.length > 0) {
|
|
150
|
+
parts.push(dim(`+${finished.length} finished`));
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
const firstLine = parts.join(" | ");
|
|
154
|
+
const lines = [
|
|
155
|
+
renderPanelRule(width, theme),
|
|
156
|
+
padLine(truncateToWidth(firstLine, innerWidth)),
|
|
157
|
+
];
|
|
158
|
+
|
|
159
|
+
if (running.length > 0) {
|
|
160
|
+
const lastLogs = this.manager.getCombinedOutput(running[0].id, 1);
|
|
161
|
+
if (lastLogs && lastLogs.length > 0) {
|
|
162
|
+
const lastLog = truncateToWidth(
|
|
163
|
+
lastLogs[lastLogs.length - 1].text,
|
|
164
|
+
innerWidth,
|
|
165
|
+
);
|
|
166
|
+
lines.push(padLine(dim(lastLog)));
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
return lines;
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
private renderOpen(width: number): string[] {
|
|
174
|
+
const theme = this.theme;
|
|
175
|
+
const dim = (s: string) => theme.fg("dim", s);
|
|
176
|
+
|
|
177
|
+
const innerWidth = width - 2;
|
|
178
|
+
const basePadLine = createPanelPadder(width);
|
|
179
|
+
const padLine = (content: string): string => {
|
|
180
|
+
const w = visibleWidth(content);
|
|
181
|
+
return basePadLine(
|
|
182
|
+
w > innerWidth ? truncateToWidth(content, innerWidth) : content,
|
|
183
|
+
);
|
|
184
|
+
};
|
|
185
|
+
|
|
186
|
+
const processes = this.manager.list();
|
|
187
|
+
const running = processes.filter((p) => LIVE_STATUSES.has(p.status));
|
|
188
|
+
|
|
189
|
+
const targetProc =
|
|
190
|
+
(this.focusedProcessId
|
|
191
|
+
? processes.find((p) => p.id === this.focusedProcessId)
|
|
192
|
+
: null) ??
|
|
193
|
+
running[0] ??
|
|
194
|
+
processes[0] ??
|
|
195
|
+
null;
|
|
196
|
+
|
|
197
|
+
if (!targetProc) {
|
|
198
|
+
return [
|
|
199
|
+
renderPanelTitleLine("Process Logs", width, theme),
|
|
200
|
+
padLine(dim("No processes")),
|
|
201
|
+
padLine(dim("Run a command to start")),
|
|
202
|
+
];
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
const logFiles = this.manager.getLogFiles(targetProc.id);
|
|
206
|
+
if (!logFiles) {
|
|
207
|
+
return [
|
|
208
|
+
renderPanelTitleLine("Process Logs", width, theme),
|
|
209
|
+
padLine(dim("Log files unavailable")),
|
|
210
|
+
];
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
const viewer = this.getViewer(targetProc.id, logFiles.combinedFile);
|
|
214
|
+
|
|
215
|
+
const logRows = Math.max(1, this.dockHeight - 2);
|
|
216
|
+
|
|
217
|
+
const title = `${targetProc.name} ${dim(`(${targetProc.id})`)}`;
|
|
218
|
+
const lines: string[] = [];
|
|
219
|
+
lines.push(renderPanelTitleLine(title, width, theme));
|
|
220
|
+
|
|
221
|
+
const contentLines = viewer.renderLines(innerWidth, logRows);
|
|
222
|
+
for (let i = 0; i < logRows; i++) {
|
|
223
|
+
lines.push(padLine(contentLines[i] ?? ""));
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
return lines.slice(0, this.dockHeight);
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
dispose(): void {
|
|
230
|
+
if (this.timer) {
|
|
231
|
+
clearInterval(this.timer);
|
|
232
|
+
this.timer = null;
|
|
233
|
+
}
|
|
234
|
+
this.unsubscribeManager?.();
|
|
235
|
+
this.viewers.clear();
|
|
236
|
+
this.processColors.clear();
|
|
237
|
+
}
|
|
238
|
+
}
|