@mjakl/pi-processes 0.8.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.
- package/README.md +137 -0
- package/package.json +68 -0
- package/src/commands/index.ts +10 -0
- package/src/commands/processes/command.ts +31 -0
- package/src/commands/processes/index.ts +1 -0
- package/src/components/log-file-viewer.ts +317 -0
- package/src/components/processes-component.ts +458 -0
- package/src/config.ts +125 -0
- package/src/constants/index.ts +11 -0
- package/src/constants/types.ts +65 -0
- package/src/hooks/background-blocker.ts +295 -0
- package/src/hooks/cleanup.ts +8 -0
- package/src/hooks/index.ts +24 -0
- package/src/hooks/message-renderer.ts +83 -0
- package/src/hooks/process-end.ts +86 -0
- package/src/hooks/status-widget.ts +65 -0
- package/src/index.ts +25 -0
- package/src/manager.ts +513 -0
- package/src/tools/actions/clear.ts +20 -0
- package/src/tools/actions/index.ts +48 -0
- package/src/tools/actions/kill.ts +107 -0
- package/src/tools/actions/list.ts +43 -0
- package/src/tools/actions/logs.ts +85 -0
- package/src/tools/actions/output.ts +186 -0
- package/src/tools/actions/start.ts +70 -0
- package/src/tools/index.ts +347 -0
- package/src/tools/tool-rendering.ts +164 -0
- package/src/utils/ansi.ts +36 -0
- package/src/utils/command-executor.ts +56 -0
- package/src/utils/format.ts +42 -0
- package/src/utils/index.ts +3 -0
- package/src/utils/process-group.ts +22 -0
- package/src/utils/shell-utils.ts +133 -0
package/README.md
ADDED
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
# Processes Extension
|
|
2
|
+
|
|
3
|
+
> **Note**
|
|
4
|
+
> This is a stripped down fork of https://github.com/aliou/pi-processes.
|
|
5
|
+
> Most people should likely use the original project instead.
|
|
6
|
+
|
|
7
|
+
Manage background processes from Pi without blocking the conversation.
|
|
8
|
+
|
|
9
|
+
## Installation
|
|
10
|
+
|
|
11
|
+
Install from npm:
|
|
12
|
+
|
|
13
|
+
```bash
|
|
14
|
+
pi install npm:@mjakl/pi-processes
|
|
15
|
+
```
|
|
16
|
+
|
|
17
|
+
For development builds, install from git:
|
|
18
|
+
|
|
19
|
+
```bash
|
|
20
|
+
pi install git:https://github.com/mjakl/pi-processes
|
|
21
|
+
```
|
|
22
|
+
|
|
23
|
+
## What this fork keeps
|
|
24
|
+
|
|
25
|
+
- the `process` tool for starting, listing, inspecting, killing, and clearing managed processes
|
|
26
|
+
- a single `/ps` overlay for monitoring processes
|
|
27
|
+
- a tiny always-visible process status line while managed processes exist, showing active/finished counts
|
|
28
|
+
- process completion notifications in the conversation
|
|
29
|
+
- file-backed logs so process output is preserved outside agent context
|
|
30
|
+
|
|
31
|
+
## What this fork removes
|
|
32
|
+
|
|
33
|
+
- all `/ps:*` subcommands
|
|
34
|
+
- the log dock
|
|
35
|
+
- the settings UI
|
|
36
|
+
- extra widget state and dock controls
|
|
37
|
+
|
|
38
|
+
## Usage
|
|
39
|
+
|
|
40
|
+
### Agent tool (LLM-facing)
|
|
41
|
+
|
|
42
|
+
The `process` tool is for the agent. Users should ask the agent to start or inspect long-running commands, then monitor them with `/ps`.
|
|
43
|
+
|
|
44
|
+
Tool-call examples:
|
|
45
|
+
|
|
46
|
+
```text
|
|
47
|
+
process start "pnpm dev" name="backend-dev"
|
|
48
|
+
process start "pnpm test --watch" name="tests"
|
|
49
|
+
process start "pnpm dev" name="backend-dev" continueAfterStart=true
|
|
50
|
+
process list
|
|
51
|
+
process output id="backend-dev"
|
|
52
|
+
process logs id="proc_1"
|
|
53
|
+
process kill id="backend-dev"
|
|
54
|
+
process kill id="proc_1" force=true
|
|
55
|
+
process clear
|
|
56
|
+
```
|
|
57
|
+
|
|
58
|
+
#### Matching processes
|
|
59
|
+
|
|
60
|
+
For `output`, `logs`, and `kill`, `id` must be either:
|
|
61
|
+
|
|
62
|
+
- the exact process ID (`proc_1`)
|
|
63
|
+
- the exact friendly process name (`backend-dev`)
|
|
64
|
+
|
|
65
|
+
If multiple processes share the same name, use the process ID.
|
|
66
|
+
|
|
67
|
+
#### Notifications for `start`
|
|
68
|
+
|
|
69
|
+
Do not poll after starting a process.
|
|
70
|
+
|
|
71
|
+
This tool is event-driven: the agent is notified automatically when a process exits, fails, or is externally killed. By default, `process start` ends the current agent turn so the agent actually waits for that notification instead of immediately polling. If the next step is waiting, call `process start` by itself rather than batching it with unrelated tools.
|
|
72
|
+
|
|
73
|
+
The intended pattern is:
|
|
74
|
+
|
|
75
|
+
1. `process start`
|
|
76
|
+
2. let the turn stop and wait for the automatic notification if the process ends
|
|
77
|
+
3. resume from that notification
|
|
78
|
+
|
|
79
|
+
Use `continueAfterStart=true` only when the agent has immediate, specific, non-polling work to do after starting the process.
|
|
80
|
+
|
|
81
|
+
Repeated `process list`, `process output`, or `process logs` calls just to see whether the process is still running are an anti-pattern.
|
|
82
|
+
|
|
83
|
+
#### Logs and output
|
|
84
|
+
|
|
85
|
+
- `process output` returns a one-off tailed stdout/stderr snapshot for agent consumption.
|
|
86
|
+
- `process logs` returns file paths for `stdout`, `stderr`, and a combined view for the `/ps` overlay.
|
|
87
|
+
- Use `output`/`logs` only when the user asks, when you need a diagnostic snapshot, or when investigating a problem - not as a polling loop.
|
|
88
|
+
|
|
89
|
+
#### Killing processes
|
|
90
|
+
|
|
91
|
+
- `process kill id="..."` sends `SIGTERM`
|
|
92
|
+
- `process kill id="..." force=true` sends `SIGKILL`
|
|
93
|
+
- tool-triggered kills never notify the agent
|
|
94
|
+
|
|
95
|
+
### `/ps` overlay
|
|
96
|
+
|
|
97
|
+
`/ps` opens the process overlay.
|
|
98
|
+
|
|
99
|
+
Inside the overlay:
|
|
100
|
+
|
|
101
|
+
- `up/down` - move the highlighted process
|
|
102
|
+
- `left/right` - scroll older/newer log output for the highlighted process
|
|
103
|
+
- `g/G` - jump to the top or back to the live tail
|
|
104
|
+
- `x` - terminate the highlighted process; press `x` again when it shows `needs kill` to force-kill it
|
|
105
|
+
- `c` - clear finished processes
|
|
106
|
+
- `q` or `Esc` - close the overlay
|
|
107
|
+
|
|
108
|
+
The right side always shows logs for the currently highlighted process.
|
|
109
|
+
|
|
110
|
+
## Configuration
|
|
111
|
+
|
|
112
|
+
Global config lives in `~/.pi/agent/extensions/process.json`.
|
|
113
|
+
|
|
114
|
+
```json
|
|
115
|
+
{
|
|
116
|
+
"output": {
|
|
117
|
+
"defaultTailLines": 100,
|
|
118
|
+
"maxOutputLines": 200
|
|
119
|
+
},
|
|
120
|
+
"execution": {
|
|
121
|
+
"shellPath": "/absolute/path/to/bash"
|
|
122
|
+
},
|
|
123
|
+
"interception": {
|
|
124
|
+
"blockBackgroundCommands": true
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
```
|
|
128
|
+
|
|
129
|
+
- `output.defaultTailLines` - default number of lines returned by `process output`
|
|
130
|
+
- `output.maxOutputLines` - hard cap for `process output`
|
|
131
|
+
- `execution.shellPath` - absolute shell path override used for process startup
|
|
132
|
+
- `interception.blockBackgroundCommands` - block shell backgrounding (`&`, `nohup`, `disown`, `setsid`) and obvious long-running foreground commands such as `pnpm dev`, `docker compose up`, `tail -f`, or `kubectl port-forward`, and guide the agent to use the `process` tool instead
|
|
133
|
+
|
|
134
|
+
## Notes
|
|
135
|
+
|
|
136
|
+
- Log files live in a temporary directory managed by the extension.
|
|
137
|
+
- Background processes are cleaned up when the session shuts down.
|
package/package.json
ADDED
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@mjakl/pi-processes",
|
|
3
|
+
"version": "0.8.0",
|
|
4
|
+
"license": "MIT",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"private": false,
|
|
7
|
+
"keywords": [
|
|
8
|
+
"pi-package",
|
|
9
|
+
"pi-extension",
|
|
10
|
+
"pi",
|
|
11
|
+
"processes"
|
|
12
|
+
],
|
|
13
|
+
"repository": {
|
|
14
|
+
"type": "git",
|
|
15
|
+
"url": "https://github.com/mjakl/pi-processes"
|
|
16
|
+
},
|
|
17
|
+
"pi": {
|
|
18
|
+
"extensions": [
|
|
19
|
+
"./src/index.ts"
|
|
20
|
+
]
|
|
21
|
+
},
|
|
22
|
+
"publishConfig": {
|
|
23
|
+
"access": "public"
|
|
24
|
+
},
|
|
25
|
+
"files": [
|
|
26
|
+
"src",
|
|
27
|
+
"!src/**/*.test.ts",
|
|
28
|
+
"README.md"
|
|
29
|
+
],
|
|
30
|
+
"dependencies": {
|
|
31
|
+
"@aliou/sh": "^0.1.0"
|
|
32
|
+
},
|
|
33
|
+
"peerDependencies": {
|
|
34
|
+
"@earendil-works/pi-coding-agent": "*",
|
|
35
|
+
"@earendil-works/pi-tui": "*",
|
|
36
|
+
"typebox": "*"
|
|
37
|
+
},
|
|
38
|
+
"devDependencies": {
|
|
39
|
+
"@biomejs/biome": "^2.3.13",
|
|
40
|
+
"@earendil-works/pi-coding-agent": "0.75.1",
|
|
41
|
+
"@earendil-works/pi-tui": "0.75.1",
|
|
42
|
+
"@types/node": "^25.0.10",
|
|
43
|
+
"husky": "^9.1.7",
|
|
44
|
+
"typebox": "^1.1.24",
|
|
45
|
+
"typescript": "^5.9.3",
|
|
46
|
+
"vitest": "^4.0.18"
|
|
47
|
+
},
|
|
48
|
+
"scripts": {
|
|
49
|
+
"typecheck": "tsc --noEmit",
|
|
50
|
+
"lint": "biome check",
|
|
51
|
+
"format": "biome check --write",
|
|
52
|
+
"test": "vitest run",
|
|
53
|
+
"check:lockfile": "pnpm install --frozen-lockfile --ignore-scripts",
|
|
54
|
+
"prepare": "[ -d .git ] && husky || true"
|
|
55
|
+
},
|
|
56
|
+
"packageManager": "pnpm@10.26.1",
|
|
57
|
+
"peerDependenciesMeta": {
|
|
58
|
+
"@earendil-works/pi-coding-agent": {
|
|
59
|
+
"optional": true
|
|
60
|
+
},
|
|
61
|
+
"@earendil-works/pi-tui": {
|
|
62
|
+
"optional": true
|
|
63
|
+
},
|
|
64
|
+
"typebox": {
|
|
65
|
+
"optional": true
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
}
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
2
|
+
import type { ProcessManager } from "../manager";
|
|
3
|
+
import { registerPsCommand } from "./processes";
|
|
4
|
+
|
|
5
|
+
export function setupProcessesCommands(
|
|
6
|
+
pi: ExtensionAPI,
|
|
7
|
+
manager: ProcessManager,
|
|
8
|
+
): void {
|
|
9
|
+
registerPsCommand(pi, manager);
|
|
10
|
+
}
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
2
|
+
import { ProcessesComponent } from "../../components/processes-component";
|
|
3
|
+
import type { ProcessManager } from "../../manager";
|
|
4
|
+
|
|
5
|
+
export function registerPsCommand(
|
|
6
|
+
pi: ExtensionAPI,
|
|
7
|
+
manager: ProcessManager,
|
|
8
|
+
): void {
|
|
9
|
+
pi.registerCommand("ps", {
|
|
10
|
+
description: "Open the process overlay",
|
|
11
|
+
handler: async (_args, ctx) => {
|
|
12
|
+
if (!ctx.hasUI) {
|
|
13
|
+
return;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
await ctx.ui.custom<null>(
|
|
17
|
+
(tui, theme, _keybindings, done) => {
|
|
18
|
+
return new ProcessesComponent(tui, theme, () => done(null), manager);
|
|
19
|
+
},
|
|
20
|
+
{
|
|
21
|
+
overlay: true,
|
|
22
|
+
overlayOptions: {
|
|
23
|
+
width: "90%",
|
|
24
|
+
maxHeight: "80%",
|
|
25
|
+
anchor: "center",
|
|
26
|
+
},
|
|
27
|
+
},
|
|
28
|
+
);
|
|
29
|
+
},
|
|
30
|
+
});
|
|
31
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export { registerPsCommand } from "./command";
|
|
@@ -0,0 +1,317 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* LogFileViewer - reads a single log file and renders a scrollable,
|
|
3
|
+
* searchable window of lines.
|
|
4
|
+
*
|
|
5
|
+
* A plain helper class (not a Component). Currently consumed by the
|
|
6
|
+
* `/ps` overlay. Callers are responsible for polling / invalidating when
|
|
7
|
+
* file content changes.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import { readFileSync } from "node:fs";
|
|
11
|
+
import type { Theme } from "@earendil-works/pi-coding-agent";
|
|
12
|
+
import { truncateToWidth, visibleWidth } from "@earendil-works/pi-tui";
|
|
13
|
+
import { stripAnsi } from "../utils";
|
|
14
|
+
|
|
15
|
+
export type StreamFilter = "combined" | "stdout" | "stderr";
|
|
16
|
+
export type LineFormat = "plain" | "combined";
|
|
17
|
+
|
|
18
|
+
interface ParsedLine {
|
|
19
|
+
type: "stdout" | "stderr";
|
|
20
|
+
text: string;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export interface LogFileViewerOptions {
|
|
24
|
+
filePath: string;
|
|
25
|
+
/** "plain" = raw lines (stdout/stderr files), "combined" = manager's 1:/2: tagged format */
|
|
26
|
+
format: LineFormat;
|
|
27
|
+
theme: Theme;
|
|
28
|
+
/** Start in follow mode (auto-scroll to tail). Default: false */
|
|
29
|
+
follow?: boolean;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export class LogFileViewer {
|
|
33
|
+
private filePath: string;
|
|
34
|
+
private format: LineFormat;
|
|
35
|
+
private theme: Theme;
|
|
36
|
+
|
|
37
|
+
private follow: boolean;
|
|
38
|
+
/** Absolute index of the last visible line (1-based).
|
|
39
|
+
* null = follow mode; always shows latest lines. */
|
|
40
|
+
private anchorEnd: number | null = null;
|
|
41
|
+
private streamFilter: StreamFilter = "combined";
|
|
42
|
+
|
|
43
|
+
private searchQuery = "";
|
|
44
|
+
private searchMatches: number[] = [];
|
|
45
|
+
private searchCurrentMatch = -1;
|
|
46
|
+
|
|
47
|
+
/** Line index (0-based) to center in the viewport. null = not centering. */
|
|
48
|
+
private centerTarget: number | null = null;
|
|
49
|
+
|
|
50
|
+
constructor(opts: LogFileViewerOptions) {
|
|
51
|
+
this.filePath = opts.filePath;
|
|
52
|
+
this.format = opts.format;
|
|
53
|
+
this.theme = opts.theme;
|
|
54
|
+
this.follow = opts.follow ?? false;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
// ---------------------------------------------------------------------------
|
|
58
|
+
// File reading
|
|
59
|
+
// ---------------------------------------------------------------------------
|
|
60
|
+
|
|
61
|
+
private readAllLines(): ParsedLine[] {
|
|
62
|
+
try {
|
|
63
|
+
const content = readFileSync(this.filePath, "utf-8");
|
|
64
|
+
const rawLines = content.split("\n");
|
|
65
|
+
// Remove trailing empty string produced by a trailing newline.
|
|
66
|
+
if (rawLines.length > 0 && rawLines[rawLines.length - 1] === "") {
|
|
67
|
+
rawLines.pop();
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
if (this.format === "plain") {
|
|
71
|
+
return rawLines.map((line) => ({
|
|
72
|
+
type: "stdout" as const,
|
|
73
|
+
text: line,
|
|
74
|
+
}));
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
// Combined format: "1:text" = stdout, "2:text" = stderr
|
|
78
|
+
return rawLines.map((line) => {
|
|
79
|
+
if (line.startsWith("2:")) {
|
|
80
|
+
return { type: "stderr" as const, text: line.slice(2) };
|
|
81
|
+
}
|
|
82
|
+
return {
|
|
83
|
+
type: "stdout" as const,
|
|
84
|
+
text: line.startsWith("1:") ? line.slice(2) : line,
|
|
85
|
+
};
|
|
86
|
+
});
|
|
87
|
+
} catch {
|
|
88
|
+
return [];
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
private applyFilter(allLines: ParsedLine[]): ParsedLine[] {
|
|
93
|
+
if (this.streamFilter === "combined") return allLines;
|
|
94
|
+
const keep = this.streamFilter === "stdout" ? "stdout" : "stderr";
|
|
95
|
+
return allLines.filter((l) => l.type === keep);
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
private computeMatches(lines: ParsedLine[]): number[] {
|
|
99
|
+
if (!this.searchQuery) return [];
|
|
100
|
+
const q = this.searchQuery.toLowerCase();
|
|
101
|
+
return lines.reduce<number[]>((acc, line, i) => {
|
|
102
|
+
if (stripAnsi(line.text).toLowerCase().includes(q)) acc.push(i);
|
|
103
|
+
return acc;
|
|
104
|
+
}, []);
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
// ---------------------------------------------------------------------------
|
|
108
|
+
// Navigation
|
|
109
|
+
// ---------------------------------------------------------------------------
|
|
110
|
+
|
|
111
|
+
scrollToTop(): void {
|
|
112
|
+
this.anchorEnd = 0;
|
|
113
|
+
this.follow = false;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
scrollToBottom(): void {
|
|
117
|
+
const lines = this.applyFilter(this.readAllLines());
|
|
118
|
+
this.anchorEnd = lines.length;
|
|
119
|
+
this.follow = false;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/** delta > 0 = scroll toward older content, delta < 0 = toward newer. */
|
|
123
|
+
scrollBy(delta: number): void {
|
|
124
|
+
if (this.anchorEnd === null) {
|
|
125
|
+
const lines = this.applyFilter(this.readAllLines());
|
|
126
|
+
this.anchorEnd = lines.length;
|
|
127
|
+
}
|
|
128
|
+
this.anchorEnd = Math.max(0, this.anchorEnd - delta);
|
|
129
|
+
this.follow = false;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
toggleFollow(): boolean {
|
|
133
|
+
this.follow = !this.follow;
|
|
134
|
+
if (this.follow) {
|
|
135
|
+
this.anchorEnd = null;
|
|
136
|
+
} else {
|
|
137
|
+
const lines = this.applyFilter(this.readAllLines());
|
|
138
|
+
this.anchorEnd = lines.length;
|
|
139
|
+
}
|
|
140
|
+
return this.follow;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
isFollowing(): boolean {
|
|
144
|
+
return this.follow;
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
cycleStreamFilter(): StreamFilter {
|
|
148
|
+
const order: StreamFilter[] = ["combined", "stdout", "stderr"];
|
|
149
|
+
this.streamFilter =
|
|
150
|
+
order[(order.indexOf(this.streamFilter) + 1) % order.length];
|
|
151
|
+
// Invalidate search since the line set changed.
|
|
152
|
+
this.searchMatches = [];
|
|
153
|
+
this.searchCurrentMatch = -1;
|
|
154
|
+
return this.streamFilter;
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
getStreamFilter(): StreamFilter {
|
|
158
|
+
return this.streamFilter;
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
// ---------------------------------------------------------------------------
|
|
162
|
+
// Search
|
|
163
|
+
// ---------------------------------------------------------------------------
|
|
164
|
+
|
|
165
|
+
setSearch(query: string): void {
|
|
166
|
+
this.searchQuery = query;
|
|
167
|
+
const lines = this.applyFilter(this.readAllLines());
|
|
168
|
+
this.searchMatches = this.computeMatches(lines);
|
|
169
|
+
if (this.searchMatches.length > 0) {
|
|
170
|
+
// Start at the last match so the user lands near the tail.
|
|
171
|
+
this.searchCurrentMatch = this.searchMatches.length - 1;
|
|
172
|
+
this.jumpToMatchLine(this.searchMatches[this.searchCurrentMatch]);
|
|
173
|
+
} else {
|
|
174
|
+
this.searchCurrentMatch = -1;
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
clearSearch(): void {
|
|
179
|
+
this.searchQuery = "";
|
|
180
|
+
this.searchMatches = [];
|
|
181
|
+
this.searchCurrentMatch = -1;
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
private jumpToMatchLine(lineIdx: number): void {
|
|
185
|
+
this.centerTarget = lineIdx;
|
|
186
|
+
this.follow = false;
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
nextMatch(): void {
|
|
190
|
+
if (this.searchMatches.length === 0) return;
|
|
191
|
+
this.searchCurrentMatch =
|
|
192
|
+
(this.searchCurrentMatch + 1) % this.searchMatches.length;
|
|
193
|
+
this.jumpToMatchLine(this.searchMatches[this.searchCurrentMatch]);
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
prevMatch(): void {
|
|
197
|
+
if (this.searchMatches.length === 0) return;
|
|
198
|
+
this.searchCurrentMatch =
|
|
199
|
+
(this.searchCurrentMatch - 1 + this.searchMatches.length) %
|
|
200
|
+
this.searchMatches.length;
|
|
201
|
+
this.jumpToMatchLine(this.searchMatches[this.searchCurrentMatch]);
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
getSearchInfo(): { query: string; current: number; total: number } | null {
|
|
205
|
+
if (!this.searchQuery) return null;
|
|
206
|
+
return {
|
|
207
|
+
query: this.searchQuery,
|
|
208
|
+
current: this.searchCurrentMatch + 1, // 1-based for display
|
|
209
|
+
total: this.searchMatches.length,
|
|
210
|
+
};
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
// ---------------------------------------------------------------------------
|
|
214
|
+
// Rendering
|
|
215
|
+
// ---------------------------------------------------------------------------
|
|
216
|
+
|
|
217
|
+
/** Returns up to `maxLines` rendered content lines. */
|
|
218
|
+
renderLines(width: number, maxLines: number): string[] {
|
|
219
|
+
const theme = this.theme;
|
|
220
|
+
const dim = (s: string) => theme.fg("dim", s);
|
|
221
|
+
const warning = (s: string) => theme.fg("warning", s);
|
|
222
|
+
|
|
223
|
+
const allLines = this.readAllLines();
|
|
224
|
+
const lines = this.applyFilter(allLines);
|
|
225
|
+
|
|
226
|
+
// Refresh matches against current (possibly grown) data.
|
|
227
|
+
if (this.searchQuery) {
|
|
228
|
+
this.searchMatches = this.computeMatches(lines);
|
|
229
|
+
if (this.searchCurrentMatch >= this.searchMatches.length) {
|
|
230
|
+
this.searchCurrentMatch = Math.max(0, this.searchMatches.length - 1);
|
|
231
|
+
}
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
const total = lines.length;
|
|
235
|
+
if (total === 0) return [dim("(no output yet)")];
|
|
236
|
+
|
|
237
|
+
// Resolve centerTarget into anchorEnd now that we know maxLines.
|
|
238
|
+
if (this.centerTarget !== null) {
|
|
239
|
+
const half = Math.floor(maxLines / 2);
|
|
240
|
+
this.anchorEnd = Math.min(total, this.centerTarget + half + 1);
|
|
241
|
+
this.centerTarget = null;
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
// Resolve anchor: null = follow (tail), number = absolute frozen end.
|
|
245
|
+
const rawEnd = this.anchorEnd ?? total;
|
|
246
|
+
// Clamp to valid range. Math.max with min(maxLines, total) ensures anchorEnd = 0
|
|
247
|
+
// (scrollToTop sentinel) still shows a full window from the top.
|
|
248
|
+
const endIdx = Math.min(total, Math.max(rawEnd, Math.min(maxLines, total)));
|
|
249
|
+
const startIdx = Math.max(0, endIdx - maxLines);
|
|
250
|
+
|
|
251
|
+
const currentMatchIdx =
|
|
252
|
+
this.searchCurrentMatch >= 0 &&
|
|
253
|
+
this.searchCurrentMatch < this.searchMatches.length
|
|
254
|
+
? this.searchMatches[this.searchCurrentMatch]
|
|
255
|
+
: -1;
|
|
256
|
+
const matchSet = new Set(this.searchMatches);
|
|
257
|
+
|
|
258
|
+
return lines.slice(startIdx, endIdx).map((line, i) => {
|
|
259
|
+
const absIdx = startIdx + i;
|
|
260
|
+
const text = truncateToWidth(stripAnsi(line.text), width);
|
|
261
|
+
|
|
262
|
+
if (absIdx === currentMatchIdx) return theme.bold(theme.inverse(text));
|
|
263
|
+
if (matchSet.has(absIdx)) return warning(text);
|
|
264
|
+
if (line.type === "stderr") return warning(text);
|
|
265
|
+
return text;
|
|
266
|
+
});
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
/**
|
|
270
|
+
* Returns a single status-bar string exactly `width` characters wide
|
|
271
|
+
* (visible width). Shows position, stream filter, and search state.
|
|
272
|
+
*/
|
|
273
|
+
renderStatusBar(width: number): string {
|
|
274
|
+
const theme = this.theme;
|
|
275
|
+
const dim = (s: string) => theme.fg("dim", s);
|
|
276
|
+
const accent = (s: string) => theme.fg("accent", s);
|
|
277
|
+
|
|
278
|
+
const lines = this.applyFilter(this.readAllLines());
|
|
279
|
+
const total = lines.length;
|
|
280
|
+
|
|
281
|
+
// Right side: position + stream filter
|
|
282
|
+
const rightParts: string[] = [];
|
|
283
|
+
if (this.follow) {
|
|
284
|
+
rightParts.push(accent("following"));
|
|
285
|
+
} else if (total === 0) {
|
|
286
|
+
rightParts.push(dim("empty"));
|
|
287
|
+
} else {
|
|
288
|
+
const rawEnd = this.anchorEnd ?? total;
|
|
289
|
+
const endIdx = Math.min(total, Math.max(0, rawEnd));
|
|
290
|
+
const pct = Math.round((endIdx / total) * 100);
|
|
291
|
+
rightParts.push(dim(`${pct}% L${Math.min(endIdx, total)}/${total}`));
|
|
292
|
+
}
|
|
293
|
+
if (this.streamFilter !== "combined") {
|
|
294
|
+
rightParts.push(dim(`[${this.streamFilter}]`));
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
// Left side: search state
|
|
298
|
+
const searchInfo = this.getSearchInfo();
|
|
299
|
+
let left = "";
|
|
300
|
+
if (searchInfo) {
|
|
301
|
+
left =
|
|
302
|
+
searchInfo.total === 0
|
|
303
|
+
? theme.fg("error", `no matches: "${searchInfo.query}"`)
|
|
304
|
+
: `${dim("/")}${searchInfo.query} ${dim(`${searchInfo.current}/${searchInfo.total}`)}`;
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
const right = rightParts.join(" ");
|
|
308
|
+
const leftW = visibleWidth(left);
|
|
309
|
+
const rightW = visibleWidth(right);
|
|
310
|
+
const gap = Math.max(1, width - leftW - rightW);
|
|
311
|
+
const bar = left + " ".repeat(gap) + right;
|
|
312
|
+
const barW = visibleWidth(bar);
|
|
313
|
+
|
|
314
|
+
if (barW > width) return truncateToWidth(bar, width);
|
|
315
|
+
return bar + " ".repeat(width - barW);
|
|
316
|
+
}
|
|
317
|
+
}
|