@tmustier/pi-files-widget 0.1.15 → 0.1.17
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/CHANGELOG.md +23 -0
- package/README.md +4 -0
- package/TODO.md +4 -4
- package/browser.ts +27 -13
- package/constants.ts +0 -1
- package/file-viewer.ts +35 -20
- package/index.ts +8 -4
- package/input-utils.ts +95 -2
- package/package.json +1 -1
- package/utils.ts +4 -0
- package/viewer.ts +187 -34
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,29 @@
|
|
|
2
2
|
|
|
3
3
|
All notable changes to this extension will be documented in this file.
|
|
4
4
|
|
|
5
|
+
## [Unreleased]
|
|
6
|
+
|
|
7
|
+
## [0.1.17] - 2026-04-19
|
|
8
|
+
|
|
9
|
+
### Changed
|
|
10
|
+
- Make the inline comment editor multiline with wrapped footer rendering, `Enter` for a new line, and `Ctrl+Enter`/`Ctrl+D` to send.
|
|
11
|
+
- Add an `m` toggle for rendered vs raw Markdown in the viewer, and fall back to raw mode before line-based search or selection.
|
|
12
|
+
- Show a sent/queued confirmation toast after returning an inline comment to the agent.
|
|
13
|
+
|
|
14
|
+
### Thanks
|
|
15
|
+
- Thanks to avg8888 in the Pi Discord for surfacing the comment editor and Markdown review issues fixed in this release.
|
|
16
|
+
|
|
17
|
+
## [0.1.16] - 2026-04-19
|
|
18
|
+
|
|
19
|
+
### Fixed
|
|
20
|
+
- Let `/readfiles` browser search accept `j` and `k` as search text instead of hijacking them for navigation.
|
|
21
|
+
- Fix viewer scrolling so the last lines of a file remain reachable.
|
|
22
|
+
- Restore `G` / `Shift+G` navigation to jump to the bottom of the viewer.
|
|
23
|
+
- Refresh an open viewer when the file changes on disk while `/readfiles` is open.
|
|
24
|
+
- Accept pasted, multi-character, and chunked bracketed-paste input in browser search and the inline comment prompt.
|
|
25
|
+
- Keep viewer search results in sync after live refreshes.
|
|
26
|
+
- Pause live refresh while a line selection or inline comment is active so comments stay anchored to what the user selected.
|
|
27
|
+
|
|
5
28
|
## [0.1.14] - 2026-02-03
|
|
6
29
|
|
|
7
30
|
### Added
|
package/README.md
CHANGED
|
@@ -113,10 +113,13 @@ If missing, `/review` or `/diff` will show a clear install prompt.
|
|
|
113
113
|
- `PgUp/PgDn`: page up/down
|
|
114
114
|
- `g/G`: top/bottom
|
|
115
115
|
- `d`: toggle diff (tracked files only)
|
|
116
|
+
- `m`: toggle rendered/raw view for Markdown files
|
|
116
117
|
- `/`: search (type to search)
|
|
117
118
|
- `n` / `N`: next/prev match
|
|
118
119
|
- `v`: select mode (line selection)
|
|
119
120
|
- `c`: comment on selected lines (inline prompt)
|
|
121
|
+
- `Enter`: new line in the comment editor
|
|
122
|
+
- `Ctrl+Enter` or `Ctrl+D`: send the comment (`Alt+Enter` also works when supported)
|
|
120
123
|
- `]` / `[`: next/prev changed file
|
|
121
124
|
- `+` / `-`: increase/decrease viewer height
|
|
122
125
|
- `q`: back to browser
|
|
@@ -124,6 +127,7 @@ If missing, `/review` or `/diff` will show a clear install prompt.
|
|
|
124
127
|
## Notes
|
|
125
128
|
|
|
126
129
|
- Untracked files show as `[UNTRACKED]` and open in normal view.
|
|
130
|
+
- Searching in rendered Markdown switches to raw mode first, and selecting from rendered Markdown first switches you back to raw so line-based matches and comments stay aligned with the source file.
|
|
127
131
|
- Folder LOCs are shown only when the folder is collapsed (expanded folders would duplicate counts).
|
|
128
132
|
- Line counts load asynchronously; the header shows activity while counts are computed.
|
|
129
133
|
- Large non-git folders load progressively and may show `[partial]` while loading in safe mode.
|
package/TODO.md
CHANGED
|
@@ -46,7 +46,7 @@
|
|
|
46
46
|
### Markdown Rendering
|
|
47
47
|
- [x] Detect `glow` availability
|
|
48
48
|
- [x] Shell out to `glow` for .md files
|
|
49
|
-
- [
|
|
49
|
+
- [x] Toggle between rendered and raw (`m`)
|
|
50
50
|
- [x] Fallback to syntax-highlighted raw
|
|
51
51
|
|
|
52
52
|
### Diff View
|
|
@@ -66,15 +66,15 @@
|
|
|
66
66
|
|
|
67
67
|
### Comment Dialog
|
|
68
68
|
- [x] `c` to open comment input
|
|
69
|
-
- [
|
|
70
|
-
- [x] `Enter`
|
|
69
|
+
- [x] Multi-line text input
|
|
70
|
+
- [x] `Enter` for newline and `Ctrl+Enter` / `Ctrl+D` to confirm
|
|
71
71
|
- [x] `Esc` to cancel
|
|
72
72
|
|
|
73
73
|
### Send to Agent
|
|
74
74
|
- [x] Format message with file path, line range, code snippet, comment
|
|
75
75
|
- [x] Use `pi.sendUserMessage()` with `deliverAs: "followUp"`
|
|
76
76
|
- [x] Handle case when agent is idle vs streaming
|
|
77
|
-
- [
|
|
77
|
+
- [x] Show confirmation notification
|
|
78
78
|
|
|
79
79
|
## Phase 4: tuicr Integration (Optional)
|
|
80
80
|
|
package/browser.ts
CHANGED
|
@@ -22,7 +22,7 @@ import { buildFileTreeFromPaths, flattenTree, getIgnoredNames, sortChildren, upd
|
|
|
22
22
|
import type { DiffStats, FileNode, FlatNode } from "./types";
|
|
23
23
|
import { isIgnoredStatus, isUntrackedStatus } from "./utils";
|
|
24
24
|
import { createViewer, type CommentPayload, type ViewerAction } from "./viewer";
|
|
25
|
-
import {
|
|
25
|
+
import { createTextInputBuffer } from "./input-utils";
|
|
26
26
|
|
|
27
27
|
export interface BrowserController {
|
|
28
28
|
render(width: number): string[];
|
|
@@ -219,6 +219,7 @@ export function createFileBrowser(
|
|
|
219
219
|
const gitBranch = repo ? getGitBranch(cwd) : "";
|
|
220
220
|
|
|
221
221
|
const viewer = createViewer(cwd, theme, requestComment);
|
|
222
|
+
const textInput = createTextInputBuffer();
|
|
222
223
|
|
|
223
224
|
const root = repo
|
|
224
225
|
? buildFileTreeFromPaths(cwd, getGitFileList(cwd), gitStatus, diffStats, ignored, agentModifiedFiles)
|
|
@@ -794,8 +795,10 @@ export function createFileBrowser(
|
|
|
794
795
|
|
|
795
796
|
function handleBrowserInput(data: string): void {
|
|
796
797
|
const displayList = getDisplayList();
|
|
798
|
+
const maxIndex = Math.max(0, displayList.length - 1);
|
|
797
799
|
|
|
798
800
|
if (matchesKey(data, "q") && !browser.searchMode) {
|
|
801
|
+
textInput.reset();
|
|
799
802
|
stopBackgroundTasks();
|
|
800
803
|
onClose();
|
|
801
804
|
return;
|
|
@@ -804,7 +807,9 @@ export function createFileBrowser(
|
|
|
804
807
|
if (browser.searchMode) {
|
|
805
808
|
browser.searchMode = false;
|
|
806
809
|
browser.searchQuery = "";
|
|
810
|
+
textInput.reset();
|
|
807
811
|
} else {
|
|
812
|
+
textInput.reset();
|
|
808
813
|
stopBackgroundTasks();
|
|
809
814
|
onClose();
|
|
810
815
|
}
|
|
@@ -813,29 +818,38 @@ export function createFileBrowser(
|
|
|
813
818
|
if (matchesKey(data, "/") && !browser.searchMode) {
|
|
814
819
|
browser.searchMode = true;
|
|
815
820
|
browser.searchQuery = "";
|
|
816
|
-
|
|
817
|
-
}
|
|
818
|
-
if (matchesKey(data, "j") || matchesKey(data, Key.down)) {
|
|
819
|
-
browser.selectedIndex = Math.min(displayList.length - 1, browser.selectedIndex + 1);
|
|
820
|
-
return;
|
|
821
|
-
}
|
|
822
|
-
if (matchesKey(data, "k") || matchesKey(data, Key.up)) {
|
|
823
|
-
browser.selectedIndex = Math.max(0, browser.selectedIndex - 1);
|
|
821
|
+
textInput.reset();
|
|
824
822
|
return;
|
|
825
823
|
}
|
|
826
824
|
if (browser.searchMode) {
|
|
827
825
|
if (matchesKey(data, Key.enter)) {
|
|
828
826
|
browser.searchMode = false;
|
|
829
827
|
browser.selectedIndex = 0;
|
|
828
|
+
textInput.reset();
|
|
830
829
|
} else if (matchesKey(data, Key.backspace)) {
|
|
831
830
|
browser.searchQuery = browser.searchQuery.slice(0, -1);
|
|
832
831
|
browser.selectedIndex = 0;
|
|
833
|
-
} else if (
|
|
834
|
-
browser.
|
|
835
|
-
|
|
832
|
+
} else if (matchesKey(data, Key.down)) {
|
|
833
|
+
browser.selectedIndex = Math.min(maxIndex, browser.selectedIndex + 1);
|
|
834
|
+
} else if (matchesKey(data, Key.up)) {
|
|
835
|
+
browser.selectedIndex = Math.max(0, browser.selectedIndex - 1);
|
|
836
|
+
} else {
|
|
837
|
+
const text = textInput.push(data);
|
|
838
|
+
if (text) {
|
|
839
|
+
browser.searchQuery += text;
|
|
840
|
+
browser.selectedIndex = 0;
|
|
841
|
+
}
|
|
836
842
|
}
|
|
837
843
|
return;
|
|
838
844
|
}
|
|
845
|
+
if (matchesKey(data, "j") || matchesKey(data, Key.down)) {
|
|
846
|
+
browser.selectedIndex = Math.min(maxIndex, browser.selectedIndex + 1);
|
|
847
|
+
return;
|
|
848
|
+
}
|
|
849
|
+
if (matchesKey(data, "k") || matchesKey(data, Key.up)) {
|
|
850
|
+
browser.selectedIndex = Math.max(0, browser.selectedIndex - 1);
|
|
851
|
+
return;
|
|
852
|
+
}
|
|
839
853
|
if (matchesKey(data, Key.enter)) {
|
|
840
854
|
const item = displayList[browser.selectedIndex];
|
|
841
855
|
if (item) {
|
|
@@ -864,7 +878,7 @@ export function createFileBrowser(
|
|
|
864
878
|
return;
|
|
865
879
|
}
|
|
866
880
|
if (matchesKey(data, Key.pageDown)) {
|
|
867
|
-
browser.selectedIndex = Math.min(
|
|
881
|
+
browser.selectedIndex = Math.min(maxIndex, browser.selectedIndex + browser.browserHeight);
|
|
868
882
|
return;
|
|
869
883
|
}
|
|
870
884
|
if (matchesKey(data, Key.pageUp)) {
|
package/constants.ts
CHANGED
package/file-viewer.ts
CHANGED
|
@@ -3,7 +3,7 @@ import { execSync } from "node:child_process";
|
|
|
3
3
|
import { readFileSync, statSync } from "node:fs";
|
|
4
4
|
|
|
5
5
|
import { isGitRepo } from "./git";
|
|
6
|
-
import { hasCommand, stripLeadingEmptyLines } from "./utils";
|
|
6
|
+
import { hasCommand, isMarkdownPath, stripLeadingEmptyLines } from "./utils";
|
|
7
7
|
|
|
8
8
|
const DIFF_CONTENT_PREFIXES = new Set(["+", "-", " "]);
|
|
9
9
|
|
|
@@ -152,14 +152,20 @@ function wrapDeltaLines(lines: string[], width: number): string[] {
|
|
|
152
152
|
return wrapped;
|
|
153
153
|
}
|
|
154
154
|
|
|
155
|
+
export interface LoadedFileContent {
|
|
156
|
+
lines: string[];
|
|
157
|
+
renderedMarkdown: boolean;
|
|
158
|
+
}
|
|
159
|
+
|
|
155
160
|
export function loadFileContent(
|
|
156
161
|
filePath: string,
|
|
157
162
|
cwd: string,
|
|
158
163
|
diffMode: boolean,
|
|
159
164
|
hasChanges: boolean,
|
|
160
|
-
width?: number
|
|
161
|
-
|
|
162
|
-
|
|
165
|
+
width?: number,
|
|
166
|
+
renderMarkdown = true
|
|
167
|
+
): LoadedFileContent {
|
|
168
|
+
const isMarkdown = isMarkdownPath(filePath);
|
|
163
169
|
const termWidth = width || process.stdout.columns || 80;
|
|
164
170
|
|
|
165
171
|
try {
|
|
@@ -195,7 +201,7 @@ export function loadFileContent(
|
|
|
195
201
|
}
|
|
196
202
|
|
|
197
203
|
if (!diffOutput.trim()) {
|
|
198
|
-
return ["No diff available - file may be untracked or unchanged"];
|
|
204
|
+
return { lines: ["No diff available - file may be untracked or unchanged"], renderedMarkdown: false };
|
|
199
205
|
}
|
|
200
206
|
|
|
201
207
|
if (hasCommand("delta")) {
|
|
@@ -211,23 +217,23 @@ export function loadFileContent(
|
|
|
211
217
|
stdio: ["pipe", "pipe", "pipe"],
|
|
212
218
|
}
|
|
213
219
|
);
|
|
214
|
-
return wrapDeltaLines(stripLeadingEmptyLines(deltaOutput.split("\n")), termWidth);
|
|
220
|
+
return { lines: wrapDeltaLines(stripLeadingEmptyLines(deltaOutput.split("\n")), termWidth), renderedMarkdown: false };
|
|
215
221
|
} catch {
|
|
216
222
|
// Fall back to raw diff
|
|
217
223
|
}
|
|
218
224
|
}
|
|
219
225
|
|
|
220
|
-
return wrapDiffLines(stripLeadingEmptyLines(diffOutput.split("\n")), termWidth);
|
|
226
|
+
return { lines: wrapDiffLines(stripLeadingEmptyLines(diffOutput.split("\n")), termWidth), renderedMarkdown: false };
|
|
221
227
|
} catch (e: any) {
|
|
222
|
-
return [`Diff error: ${e.message}`];
|
|
228
|
+
return { lines: [`Diff error: ${e.message}`], renderedMarkdown: false };
|
|
223
229
|
}
|
|
224
230
|
}
|
|
225
231
|
|
|
226
|
-
if (isMarkdown && hasCommand("glow")) {
|
|
232
|
+
if (isMarkdown && renderMarkdown && hasCommand("glow")) {
|
|
227
233
|
try {
|
|
228
234
|
const output = execSync(`glow -s dark -w ${termWidth} "${filePath}"`, { encoding: "utf-8", timeout: 10000 });
|
|
229
235
|
if (output.trim()) {
|
|
230
|
-
return stripLeadingEmptyLines(output.split("\n"));
|
|
236
|
+
return { lines: stripLeadingEmptyLines(output.split("\n")), renderedMarkdown: true };
|
|
231
237
|
}
|
|
232
238
|
} catch {
|
|
233
239
|
// Fall through to bat
|
|
@@ -236,16 +242,22 @@ export function loadFileContent(
|
|
|
236
242
|
|
|
237
243
|
if (hasCommand("bat")) {
|
|
238
244
|
try {
|
|
239
|
-
return
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
245
|
+
return {
|
|
246
|
+
lines: execSync(
|
|
247
|
+
`bat --style=numbers --color=always --paging=never --wrap=auto --terminal-width=${termWidth} "${filePath}"`,
|
|
248
|
+
{ encoding: "utf-8", timeout: 10000 }
|
|
249
|
+
).split("\n"),
|
|
250
|
+
renderedMarkdown: false,
|
|
251
|
+
};
|
|
243
252
|
} catch {
|
|
244
253
|
try {
|
|
245
|
-
return
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
254
|
+
return {
|
|
255
|
+
lines: execSync(
|
|
256
|
+
`bat --style=numbers --color=always --paging=never --terminal-width=${termWidth} "${filePath}"`,
|
|
257
|
+
{ encoding: "utf-8", timeout: 10000 }
|
|
258
|
+
).split("\n"),
|
|
259
|
+
renderedMarkdown: false,
|
|
260
|
+
};
|
|
249
261
|
} catch {
|
|
250
262
|
// Fall through to raw file read
|
|
251
263
|
}
|
|
@@ -253,8 +265,11 @@ export function loadFileContent(
|
|
|
253
265
|
}
|
|
254
266
|
|
|
255
267
|
const raw = readFileSync(filePath, "utf-8");
|
|
256
|
-
return
|
|
268
|
+
return {
|
|
269
|
+
lines: raw.split("\n").map((line, i) => `${String(i + 1).padStart(4)} │ ${line}`),
|
|
270
|
+
renderedMarkdown: false,
|
|
271
|
+
};
|
|
257
272
|
} catch (e: any) {
|
|
258
|
-
return [`Error loading file: ${e.message}`];
|
|
273
|
+
return { lines: [`Error loading file: ${e.message}`], renderedMarkdown: false };
|
|
259
274
|
}
|
|
260
275
|
}
|
package/index.ts
CHANGED
|
@@ -41,10 +41,14 @@ export default function editorExtension(pi: ExtensionAPI): void {
|
|
|
41
41
|
};
|
|
42
42
|
|
|
43
43
|
const requestComment = (payload: { relPath: string; lineRange: string; ext: string; selectedText: string }, comment: string) => {
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
44
|
+
const message = formatCommentMessage(payload, comment);
|
|
45
|
+
if (ctx.isIdle()) {
|
|
46
|
+
pi.sendUserMessage(message);
|
|
47
|
+
ctx.ui.notify(`Comment sent to agent for ${payload.relPath} (${payload.lineRange})`, "success");
|
|
48
|
+
} else {
|
|
49
|
+
pi.sendUserMessage(message, { deliverAs: "followUp" });
|
|
50
|
+
ctx.ui.notify(`Comment queued for agent for ${payload.relPath} (${payload.lineRange})`, "info");
|
|
51
|
+
}
|
|
48
52
|
};
|
|
49
53
|
|
|
50
54
|
const requestRender = () => tui.requestRender();
|
package/input-utils.ts
CHANGED
|
@@ -1,3 +1,96 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
1
|
+
import { decodeKittyPrintable } from "@mariozechner/pi-tui";
|
|
2
|
+
|
|
3
|
+
const CONTROL_CHARS = /[\u0000-\u0008\u000B-\u001F\u007F]/g;
|
|
4
|
+
const BRACKETED_PASTE_START = "\u001b[200~";
|
|
5
|
+
const BRACKETED_PASTE_END = "\u001b[201~";
|
|
6
|
+
|
|
7
|
+
interface SanitizeTextInputOptions {
|
|
8
|
+
preserveNewlines?: boolean;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
function sanitizeTextInput(data: string, options: SanitizeTextInputOptions = {}): string {
|
|
12
|
+
const normalized = decodeKittyPrintable(data) ?? data;
|
|
13
|
+
if (!normalized || normalized.includes("\u001b")) {
|
|
14
|
+
return "";
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
const withNewlines = normalized.replace(/\r\n?/g, "\n");
|
|
18
|
+
const withoutTabs = options.preserveNewlines
|
|
19
|
+
? withNewlines.replace(/\t/g, " ")
|
|
20
|
+
: withNewlines.replace(/\n/g, " ").replace(/\t/g, " ");
|
|
21
|
+
|
|
22
|
+
return withoutTabs.replace(CONTROL_CHARS, "");
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function getPendingStartSuffix(data: string): string {
|
|
26
|
+
const maxLength = BRACKETED_PASTE_START.length - 1;
|
|
27
|
+
for (let length = Math.min(data.length, maxLength); length > 0; length--) {
|
|
28
|
+
const suffix = data.slice(-length);
|
|
29
|
+
if (BRACKETED_PASTE_START.startsWith(suffix)) {
|
|
30
|
+
return suffix;
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
return "";
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export interface TextInputBuffer {
|
|
37
|
+
push(data: string): string;
|
|
38
|
+
reset(): void;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
interface TextInputBufferOptions {
|
|
42
|
+
preserveNewlines?: boolean;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export function createTextInputBuffer(options: TextInputBufferOptions = {}): TextInputBuffer {
|
|
46
|
+
let isInPaste = false;
|
|
47
|
+
let pasteBuffer = "";
|
|
48
|
+
let pendingStart = "";
|
|
49
|
+
|
|
50
|
+
const push = (data: string): string => {
|
|
51
|
+
if (!data) {
|
|
52
|
+
return "";
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
const combined = pendingStart + data;
|
|
56
|
+
pendingStart = "";
|
|
57
|
+
|
|
58
|
+
if (!isInPaste) {
|
|
59
|
+
const startIndex = combined.indexOf(BRACKETED_PASTE_START);
|
|
60
|
+
if (startIndex === -1) {
|
|
61
|
+
const pendingSuffix = getPendingStartSuffix(combined);
|
|
62
|
+
const completeText = pendingSuffix ? combined.slice(0, combined.length - pendingSuffix.length) : combined;
|
|
63
|
+
pendingStart = pendingSuffix;
|
|
64
|
+
return sanitizeTextInput(completeText, options);
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
const beforePaste = combined.slice(0, startIndex);
|
|
68
|
+
const afterStart = combined.slice(startIndex + BRACKETED_PASTE_START.length);
|
|
69
|
+
isInPaste = true;
|
|
70
|
+
pasteBuffer = "";
|
|
71
|
+
return sanitizeTextInput(beforePaste, options) + push(afterStart);
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
pasteBuffer += combined;
|
|
75
|
+
const endIndex = pasteBuffer.indexOf(BRACKETED_PASTE_END);
|
|
76
|
+
if (endIndex === -1) {
|
|
77
|
+
return "";
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
const pastedText = pasteBuffer.slice(0, endIndex);
|
|
81
|
+
const remaining = pasteBuffer.slice(endIndex + BRACKETED_PASTE_END.length);
|
|
82
|
+
isInPaste = false;
|
|
83
|
+
pasteBuffer = "";
|
|
84
|
+
|
|
85
|
+
return sanitizeTextInput(pastedText, options) + push(remaining);
|
|
86
|
+
};
|
|
87
|
+
|
|
88
|
+
return {
|
|
89
|
+
push,
|
|
90
|
+
reset(): void {
|
|
91
|
+
isInPaste = false;
|
|
92
|
+
pasteBuffer = "";
|
|
93
|
+
pendingStart = "";
|
|
94
|
+
},
|
|
95
|
+
};
|
|
3
96
|
}
|
package/package.json
CHANGED
package/utils.ts
CHANGED
|
@@ -17,6 +17,10 @@ export function isIgnoredStatus(status?: string): boolean {
|
|
|
17
17
|
return status === "!" || status === "!!";
|
|
18
18
|
}
|
|
19
19
|
|
|
20
|
+
export function isMarkdownPath(path: string): boolean {
|
|
21
|
+
return path.toLowerCase().endsWith(".md");
|
|
22
|
+
}
|
|
23
|
+
|
|
20
24
|
export function stripLeadingEmptyLines(lines: string[]): string[] {
|
|
21
25
|
let startIdx = 0;
|
|
22
26
|
while (startIdx < lines.length && !lines[startIdx].trim()) {
|
package/viewer.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import type { Theme } from "@mariozechner/pi-coding-agent";
|
|
2
|
-
import { Key, matchesKey, truncateToWidth } from "@mariozechner/pi-tui";
|
|
3
|
-
import { readFileSync } from "node:fs";
|
|
2
|
+
import { Key, matchesKey, truncateToWidth, wrapTextWithAnsi } from "@mariozechner/pi-tui";
|
|
3
|
+
import { readFileSync, statSync } from "node:fs";
|
|
4
4
|
import { relative } from "node:path";
|
|
5
5
|
|
|
6
6
|
import {
|
|
@@ -8,12 +8,13 @@ import {
|
|
|
8
8
|
MAX_VIEWER_HEIGHT,
|
|
9
9
|
MIN_PANEL_HEIGHT,
|
|
10
10
|
SEARCH_SCROLL_OFFSET,
|
|
11
|
-
VIEWER_SCROLL_MARGIN,
|
|
12
11
|
} from "./constants";
|
|
13
12
|
import { loadFileContent } from "./file-viewer";
|
|
14
13
|
import type { FileNode } from "./types";
|
|
15
|
-
import { isUntrackedStatus } from "./utils";
|
|
16
|
-
import {
|
|
14
|
+
import { isMarkdownPath, isUntrackedStatus } from "./utils";
|
|
15
|
+
import { createTextInputBuffer } from "./input-utils";
|
|
16
|
+
|
|
17
|
+
const COMMENT_EDITOR_MAX_VISIBLE_LINES = 4;
|
|
17
18
|
|
|
18
19
|
export interface CommentPayload {
|
|
19
20
|
relPath: string;
|
|
@@ -35,6 +36,7 @@ interface ViewerState {
|
|
|
35
36
|
rawContent: string;
|
|
36
37
|
scroll: number;
|
|
37
38
|
diffMode: boolean;
|
|
39
|
+
renderMarkdown: boolean;
|
|
38
40
|
mode: ViewerMode;
|
|
39
41
|
selectStart: number;
|
|
40
42
|
selectEnd: number;
|
|
@@ -43,6 +45,7 @@ interface ViewerState {
|
|
|
43
45
|
searchMatches: number[];
|
|
44
46
|
searchIndex: number;
|
|
45
47
|
lastRenderWidth: number;
|
|
48
|
+
lastLoadedMtimeMs: number | null;
|
|
46
49
|
height: number;
|
|
47
50
|
}
|
|
48
51
|
|
|
@@ -61,12 +64,16 @@ export function createViewer(
|
|
|
61
64
|
theme: Theme,
|
|
62
65
|
requestComment: (payload: CommentPayload, comment: string) => void
|
|
63
66
|
): ViewerController {
|
|
67
|
+
const searchInput = createTextInputBuffer();
|
|
68
|
+
const commentInput = createTextInputBuffer({ preserveNewlines: true });
|
|
69
|
+
|
|
64
70
|
const state: ViewerState = {
|
|
65
71
|
file: null,
|
|
66
72
|
content: [],
|
|
67
73
|
rawContent: "",
|
|
68
74
|
scroll: 0,
|
|
69
75
|
diffMode: false,
|
|
76
|
+
renderMarkdown: true,
|
|
70
77
|
mode: "normal",
|
|
71
78
|
selectStart: 0,
|
|
72
79
|
selectEnd: 0,
|
|
@@ -75,9 +82,35 @@ export function createViewer(
|
|
|
75
82
|
searchMatches: [],
|
|
76
83
|
searchIndex: 0,
|
|
77
84
|
lastRenderWidth: 0,
|
|
85
|
+
lastLoadedMtimeMs: null,
|
|
78
86
|
height: DEFAULT_VIEWER_HEIGHT,
|
|
79
87
|
};
|
|
80
88
|
|
|
89
|
+
function isMarkdownFile(): boolean {
|
|
90
|
+
return !!state.file && isMarkdownPath(state.file.path);
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
function isRenderedMarkdownMode(): boolean {
|
|
94
|
+
return isMarkdownFile() && !state.diffMode && state.renderMarkdown;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
function switchMarkdownToRaw(): boolean {
|
|
98
|
+
if (!isRenderedMarkdownMode()) return false;
|
|
99
|
+
state.renderMarkdown = false;
|
|
100
|
+
const width = state.lastRenderWidth || process.stdout.columns || 80;
|
|
101
|
+
reloadContent(width);
|
|
102
|
+
return true;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
function toggleMarkdownMode(): void {
|
|
106
|
+
if (!isMarkdownFile() || state.diffMode) return;
|
|
107
|
+
state.renderMarkdown = !state.renderMarkdown;
|
|
108
|
+
state.lastRenderWidth = 0;
|
|
109
|
+
resetSearch();
|
|
110
|
+
setMode("normal");
|
|
111
|
+
clampScroll();
|
|
112
|
+
}
|
|
113
|
+
|
|
81
114
|
function resetSearch(): void {
|
|
82
115
|
state.searchQuery = "";
|
|
83
116
|
state.searchMatches = [];
|
|
@@ -94,6 +127,11 @@ export function createViewer(
|
|
|
94
127
|
}
|
|
95
128
|
|
|
96
129
|
function setMode(mode: ViewerMode): void {
|
|
130
|
+
if (mode !== state.mode) {
|
|
131
|
+
searchInput.reset();
|
|
132
|
+
commentInput.reset();
|
|
133
|
+
}
|
|
134
|
+
|
|
97
135
|
state.mode = mode;
|
|
98
136
|
if (mode !== "search") resetSearch();
|
|
99
137
|
if (mode !== "comment") resetComment();
|
|
@@ -102,16 +140,61 @@ export function createViewer(
|
|
|
102
140
|
}
|
|
103
141
|
}
|
|
104
142
|
|
|
143
|
+
function getMaxScroll(): number {
|
|
144
|
+
return Math.max(0, state.content.length - state.height);
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
function refreshRawContent(): void {
|
|
148
|
+
if (!state.file) return;
|
|
149
|
+
|
|
150
|
+
try {
|
|
151
|
+
const fileStat = statSync(state.file.path);
|
|
152
|
+
state.rawContent = readFileSync(state.file.path, "utf-8");
|
|
153
|
+
state.file.lineCount = state.rawContent.split("\n").length;
|
|
154
|
+
state.lastLoadedMtimeMs = fileStat.mtimeMs;
|
|
155
|
+
} catch {
|
|
156
|
+
state.rawContent = "";
|
|
157
|
+
state.file.lineCount = undefined;
|
|
158
|
+
state.lastLoadedMtimeMs = null;
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
function hasFileChangedOnDisk(): boolean {
|
|
163
|
+
if (!state.file) return false;
|
|
164
|
+
|
|
165
|
+
try {
|
|
166
|
+
return state.lastLoadedMtimeMs === null || statSync(state.file.path).mtimeMs !== state.lastLoadedMtimeMs;
|
|
167
|
+
} catch {
|
|
168
|
+
return state.lastLoadedMtimeMs !== null;
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
function clampScroll(): void {
|
|
173
|
+
state.scroll = Math.min(getMaxScroll(), Math.max(0, state.scroll));
|
|
174
|
+
}
|
|
175
|
+
|
|
105
176
|
function reloadContent(width: number): void {
|
|
106
177
|
if (!state.file) return;
|
|
178
|
+
refreshRawContent();
|
|
107
179
|
const hasChanges = !!state.file.gitStatus;
|
|
108
|
-
|
|
180
|
+
const result = loadFileContent(state.file.path, cwd, state.diffMode, hasChanges, width, state.renderMarkdown);
|
|
181
|
+
state.content = result.lines;
|
|
182
|
+
state.renderMarkdown = result.renderedMarkdown;
|
|
109
183
|
state.lastRenderWidth = width;
|
|
184
|
+
clampScroll();
|
|
185
|
+
if (state.searchQuery) {
|
|
186
|
+
updateSearchMatches({ preserveActiveMatch: true });
|
|
187
|
+
}
|
|
110
188
|
}
|
|
111
189
|
|
|
112
|
-
function updateSearchMatches(): void {
|
|
190
|
+
function updateSearchMatches(options: { preserveActiveMatch?: boolean } = {}): void {
|
|
191
|
+
const activeMatch = options.preserveActiveMatch ? state.searchMatches[state.searchIndex] : undefined;
|
|
192
|
+
|
|
113
193
|
state.searchMatches = [];
|
|
114
|
-
if (!state.searchQuery)
|
|
194
|
+
if (!state.searchQuery) {
|
|
195
|
+
state.searchIndex = 0;
|
|
196
|
+
return;
|
|
197
|
+
}
|
|
115
198
|
|
|
116
199
|
const q = state.searchQuery.toLowerCase();
|
|
117
200
|
const rawLines = state.rawContent.split("\n");
|
|
@@ -120,11 +203,29 @@ export function createViewer(
|
|
|
120
203
|
state.searchMatches.push(i);
|
|
121
204
|
}
|
|
122
205
|
}
|
|
123
|
-
state.searchIndex = 0;
|
|
124
206
|
|
|
125
|
-
if (state.searchMatches.length
|
|
126
|
-
state.
|
|
207
|
+
if (state.searchMatches.length === 0) {
|
|
208
|
+
state.searchIndex = 0;
|
|
209
|
+
return;
|
|
127
210
|
}
|
|
211
|
+
|
|
212
|
+
if (activeMatch === undefined) {
|
|
213
|
+
state.searchIndex = 0;
|
|
214
|
+
} else {
|
|
215
|
+
let nearestIndex = 0;
|
|
216
|
+
let nearestDistance = Number.POSITIVE_INFINITY;
|
|
217
|
+
for (let i = 0; i < state.searchMatches.length; i++) {
|
|
218
|
+
const distance = Math.abs(state.searchMatches[i] - activeMatch);
|
|
219
|
+
if (distance < nearestDistance) {
|
|
220
|
+
nearestDistance = distance;
|
|
221
|
+
nearestIndex = i;
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
state.searchIndex = nearestIndex;
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
state.scroll = Math.max(0, state.searchMatches[state.searchIndex] - SEARCH_SCROLL_OFFSET);
|
|
228
|
+
clampScroll();
|
|
128
229
|
}
|
|
129
230
|
|
|
130
231
|
function jumpToNextMatch(direction: 1 | -1): void {
|
|
@@ -133,6 +234,7 @@ export function createViewer(
|
|
|
133
234
|
if (state.searchIndex < 0) state.searchIndex = state.searchMatches.length - 1;
|
|
134
235
|
if (state.searchIndex >= state.searchMatches.length) state.searchIndex = 0;
|
|
135
236
|
state.scroll = Math.max(0, state.searchMatches[state.searchIndex] - SEARCH_SCROLL_OFFSET);
|
|
237
|
+
clampScroll();
|
|
136
238
|
}
|
|
137
239
|
|
|
138
240
|
function buildCommentPayload(): CommentPayload | null {
|
|
@@ -166,6 +268,8 @@ export function createViewer(
|
|
|
166
268
|
header += theme.fg("dim", " [UNTRACKED]");
|
|
167
269
|
} else if (state.diffMode) {
|
|
168
270
|
header += theme.fg("warning", " [DIFF]");
|
|
271
|
+
} else if (isMarkdownFile()) {
|
|
272
|
+
header += theme.fg("accent", state.renderMarkdown ? " [RENDERED]" : " [RAW]");
|
|
169
273
|
}
|
|
170
274
|
if (state.mode === "select" || state.mode === "comment") {
|
|
171
275
|
header += theme.fg("accent", ` [SELECT ${state.selectStart + 1}-${state.selectEnd + 1}]`);
|
|
@@ -195,6 +299,38 @@ export function createViewer(
|
|
|
195
299
|
return truncateToWidth(header, width);
|
|
196
300
|
}
|
|
197
301
|
|
|
302
|
+
function renderCommentEditor(width: number): string[] {
|
|
303
|
+
const contentWidth = Math.max(1, width - 2);
|
|
304
|
+
const wrappedLines: string[] = [];
|
|
305
|
+
const logicalLines = state.commentText.split("\n");
|
|
306
|
+
|
|
307
|
+
for (const line of logicalLines) {
|
|
308
|
+
if (line.length === 0) {
|
|
309
|
+
wrappedLines.push("");
|
|
310
|
+
continue;
|
|
311
|
+
}
|
|
312
|
+
wrappedLines.push(...wrapTextWithAnsi(line, contentWidth));
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
if (wrappedLines.length === 0) {
|
|
316
|
+
wrappedLines.push("");
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
const lastIndex = wrappedLines.length - 1;
|
|
320
|
+
wrappedLines[lastIndex] = `${wrappedLines[lastIndex]}█`;
|
|
321
|
+
|
|
322
|
+
const overflow = Math.max(0, wrappedLines.length - COMMENT_EDITOR_MAX_VISIBLE_LINES);
|
|
323
|
+
const visibleLines = wrappedLines.slice(-COMMENT_EDITOR_MAX_VISIBLE_LINES);
|
|
324
|
+
if (overflow > 0 && visibleLines.length > 0) {
|
|
325
|
+
visibleLines[0] = `…${visibleLines[0]}`;
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
return [
|
|
329
|
+
truncateToWidth(theme.fg("accent", "Comment:"), width),
|
|
330
|
+
...visibleLines.map(line => truncateToWidth(` ${line}`, width)),
|
|
331
|
+
];
|
|
332
|
+
}
|
|
333
|
+
|
|
198
334
|
function renderFooter(width: number): string[] {
|
|
199
335
|
const lines: string[] = [];
|
|
200
336
|
const pct = state.content.length > 0
|
|
@@ -202,23 +338,23 @@ export function createViewer(
|
|
|
202
338
|
: 0;
|
|
203
339
|
|
|
204
340
|
if (state.mode === "comment") {
|
|
205
|
-
|
|
206
|
-
lines.push(truncateToWidth(prompt, width));
|
|
341
|
+
lines.push(...renderCommentEditor(width));
|
|
207
342
|
lines.push(theme.fg("borderMuted", "─".repeat(width)));
|
|
208
343
|
}
|
|
209
344
|
|
|
210
345
|
let help: string;
|
|
211
346
|
if (state.mode === "comment") {
|
|
212
|
-
help = theme.fg("dim", "Enter: send Esc: cancel");
|
|
347
|
+
help = theme.fg("dim", "Enter: newline Ctrl+Enter/Ctrl+D: send Esc: cancel");
|
|
213
348
|
} else if (state.mode === "select") {
|
|
214
349
|
help = theme.fg("dim", "j/k: extend c: comment Esc: cancel");
|
|
215
350
|
} else if (state.mode === "search") {
|
|
216
351
|
help = theme.fg("dim", "Type to search Enter: confirm Esc: cancel");
|
|
217
352
|
} else {
|
|
218
353
|
const isUntracked = state.file && isUntrackedStatus(state.file.gitStatus);
|
|
354
|
+
const markdownHelp = isMarkdownFile() && !state.diffMode ? "m: raw/render " : "";
|
|
219
355
|
help = theme.fg(
|
|
220
356
|
"dim",
|
|
221
|
-
`j/k: scroll /: search n/N: next/prev match []: files ${state.file?.gitStatus && !isUntracked ? "d: diff " : ""}q: back ${pct}%`
|
|
357
|
+
`j/k: scroll /: search n/N: next/prev match ${markdownHelp}[]: files ${state.file?.gitStatus && !isUntracked ? "d: diff " : ""}q: back ${pct}%`
|
|
222
358
|
);
|
|
223
359
|
}
|
|
224
360
|
lines.push(truncateToWidth(help, width));
|
|
@@ -239,17 +375,12 @@ export function createViewer(
|
|
|
239
375
|
state.file = file;
|
|
240
376
|
state.scroll = 0;
|
|
241
377
|
state.diffMode = !!file.gitStatus && !isUntrackedStatus(file.gitStatus);
|
|
378
|
+
state.renderMarkdown = isMarkdownPath(file.path);
|
|
242
379
|
setMode("normal");
|
|
243
380
|
state.content = [];
|
|
244
381
|
state.lastRenderWidth = 0;
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
state.rawContent = readFileSync(file.path, "utf-8");
|
|
248
|
-
file.lineCount = state.rawContent.split("\n").length;
|
|
249
|
-
} catch {
|
|
250
|
-
state.rawContent = "";
|
|
251
|
-
file.lineCount = undefined;
|
|
252
|
-
}
|
|
382
|
+
state.lastLoadedMtimeMs = null;
|
|
383
|
+
refreshRawContent();
|
|
253
384
|
},
|
|
254
385
|
|
|
255
386
|
updateFileRef(file: FileNode | null): void {
|
|
@@ -259,13 +390,17 @@ export function createViewer(
|
|
|
259
390
|
close(): void {
|
|
260
391
|
state.file = null;
|
|
261
392
|
state.content = [];
|
|
393
|
+
state.rawContent = "";
|
|
394
|
+
state.renderMarkdown = true;
|
|
395
|
+
state.lastLoadedMtimeMs = null;
|
|
262
396
|
setMode("normal");
|
|
263
397
|
},
|
|
264
398
|
|
|
265
399
|
render(width: number): string[] {
|
|
266
400
|
if (!state.file) return [];
|
|
267
401
|
|
|
268
|
-
|
|
402
|
+
const shouldAutoRefresh = state.mode !== "select" && state.mode !== "comment";
|
|
403
|
+
if (state.lastRenderWidth !== width || state.content.length === 0 || (shouldAutoRefresh && hasFileChangedOnDisk())) {
|
|
269
404
|
reloadContent(width);
|
|
270
405
|
}
|
|
271
406
|
|
|
@@ -297,19 +432,24 @@ export function createViewer(
|
|
|
297
432
|
if (!state.file) return { type: "none" };
|
|
298
433
|
|
|
299
434
|
if (state.mode === "comment") {
|
|
300
|
-
if (matchesKey(data,
|
|
435
|
+
if (matchesKey(data, "ctrl+enter") || matchesKey(data, "ctrl+d") || matchesKey(data, "alt+enter")) {
|
|
301
436
|
const comment = state.commentText.trim();
|
|
302
437
|
if (comment) {
|
|
303
438
|
sendComment(comment);
|
|
304
439
|
} else {
|
|
305
440
|
setMode("normal");
|
|
306
441
|
}
|
|
442
|
+
} else if (matchesKey(data, Key.enter) || matchesKey(data, "shift+enter")) {
|
|
443
|
+
state.commentText += "\n";
|
|
307
444
|
} else if (matchesKey(data, Key.escape)) {
|
|
308
445
|
setMode("normal");
|
|
309
446
|
} else if (matchesKey(data, Key.backspace)) {
|
|
310
447
|
state.commentText = state.commentText.slice(0, -1);
|
|
311
|
-
} else
|
|
312
|
-
|
|
448
|
+
} else {
|
|
449
|
+
const text = commentInput.push(data);
|
|
450
|
+
if (text) {
|
|
451
|
+
state.commentText += text;
|
|
452
|
+
}
|
|
313
453
|
}
|
|
314
454
|
return { type: "none" };
|
|
315
455
|
}
|
|
@@ -322,9 +462,12 @@ export function createViewer(
|
|
|
322
462
|
} else if (matchesKey(data, Key.backspace)) {
|
|
323
463
|
state.searchQuery = state.searchQuery.slice(0, -1);
|
|
324
464
|
updateSearchMatches();
|
|
325
|
-
} else
|
|
326
|
-
|
|
327
|
-
|
|
465
|
+
} else {
|
|
466
|
+
const text = searchInput.push(data);
|
|
467
|
+
if (text) {
|
|
468
|
+
state.searchQuery += text;
|
|
469
|
+
updateSearchMatches();
|
|
470
|
+
}
|
|
328
471
|
}
|
|
329
472
|
return { type: "none" };
|
|
330
473
|
}
|
|
@@ -343,6 +486,7 @@ export function createViewer(
|
|
|
343
486
|
return { type: "none" };
|
|
344
487
|
}
|
|
345
488
|
if (matchesKey(data, "/") && state.mode !== "select") {
|
|
489
|
+
switchMarkdownToRaw();
|
|
346
490
|
setMode("search");
|
|
347
491
|
return { type: "none" };
|
|
348
492
|
}
|
|
@@ -358,7 +502,7 @@ export function createViewer(
|
|
|
358
502
|
if (state.mode === "select") {
|
|
359
503
|
state.selectEnd = Math.min(state.content.length - 1, state.selectEnd + 1);
|
|
360
504
|
} else {
|
|
361
|
-
state.scroll = Math.min(
|
|
505
|
+
state.scroll = Math.min(getMaxScroll(), state.scroll + 1);
|
|
362
506
|
}
|
|
363
507
|
return { type: "none" };
|
|
364
508
|
}
|
|
@@ -371,7 +515,7 @@ export function createViewer(
|
|
|
371
515
|
return { type: "none" };
|
|
372
516
|
}
|
|
373
517
|
if (matchesKey(data, Key.pageDown)) {
|
|
374
|
-
state.scroll = Math.min(
|
|
518
|
+
state.scroll = Math.min(getMaxScroll(), state.scroll + state.height);
|
|
375
519
|
return { type: "none" };
|
|
376
520
|
}
|
|
377
521
|
if (matchesKey(data, Key.pageUp)) {
|
|
@@ -382,16 +526,18 @@ export function createViewer(
|
|
|
382
526
|
state.scroll = 0;
|
|
383
527
|
return { type: "none" };
|
|
384
528
|
}
|
|
385
|
-
if (matchesKey(data, "
|
|
386
|
-
state.scroll =
|
|
529
|
+
if (matchesKey(data, "shift+g")) {
|
|
530
|
+
state.scroll = getMaxScroll();
|
|
387
531
|
return { type: "none" };
|
|
388
532
|
}
|
|
389
533
|
if (matchesKey(data, "+") || matchesKey(data, "=")) {
|
|
390
534
|
state.height = Math.min(MAX_VIEWER_HEIGHT, state.height + 5);
|
|
535
|
+
clampScroll();
|
|
391
536
|
return { type: "none" };
|
|
392
537
|
}
|
|
393
538
|
if (matchesKey(data, "-") || matchesKey(data, "_")) {
|
|
394
539
|
state.height = Math.max(MIN_PANEL_HEIGHT, state.height - 5);
|
|
540
|
+
clampScroll();
|
|
395
541
|
return { type: "none" };
|
|
396
542
|
}
|
|
397
543
|
if (matchesKey(data, "d") && state.mode !== "select" && state.file.gitStatus && !isUntrackedStatus(state.file.gitStatus)) {
|
|
@@ -400,7 +546,14 @@ export function createViewer(
|
|
|
400
546
|
state.scroll = 0;
|
|
401
547
|
return { type: "none" };
|
|
402
548
|
}
|
|
549
|
+
if (matchesKey(data, "m") && state.mode !== "select" && state.mode !== "comment") {
|
|
550
|
+
toggleMarkdownMode();
|
|
551
|
+
return { type: "none" };
|
|
552
|
+
}
|
|
403
553
|
if (matchesKey(data, "v") && state.mode !== "select") {
|
|
554
|
+
if (switchMarkdownToRaw()) {
|
|
555
|
+
return { type: "none" };
|
|
556
|
+
}
|
|
404
557
|
state.mode = "select";
|
|
405
558
|
state.selectStart = state.scroll;
|
|
406
559
|
state.selectEnd = state.scroll;
|