@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 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
- - [ ] Toggle between rendered and raw (`m` key?)
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
- - [ ] Multi-line text input
70
- - [x] `Enter` or `Ctrl+Enter` to confirm
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
- - [ ] Show confirmation notification
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 { isPrintableChar } from "./input-utils";
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
- return;
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 (isPrintableChar(data)) {
834
- browser.searchQuery += data;
835
- browser.selectedIndex = 0;
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(displayList.length - 1, browser.selectedIndex + browser.browserHeight);
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
@@ -14,5 +14,4 @@ export const MIN_PANEL_HEIGHT = 5;
14
14
  export const MAX_VIEWER_HEIGHT = 50;
15
15
  export const MAX_BROWSER_HEIGHT = 40;
16
16
 
17
- export const VIEWER_SCROLL_MARGIN = 10;
18
17
  export const SEARCH_SCROLL_OFFSET = 3;
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
- ): string[] {
162
- const isMarkdown = filePath.endsWith(".md");
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 execSync(
240
- `bat --style=numbers --color=always --paging=never --wrap=auto --terminal-width=${termWidth} "${filePath}"`,
241
- { encoding: "utf-8", timeout: 10000 }
242
- ).split("\n");
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 execSync(
246
- `bat --style=numbers --color=always --paging=never --terminal-width=${termWidth} "${filePath}"`,
247
- { encoding: "utf-8", timeout: 10000 }
248
- ).split("\n");
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 raw.split("\n").map((line, i) => `${String(i + 1).padStart(4)} │ ${line}`);
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
- pi.sendUserMessage(formatCommentMessage(payload, comment), {
45
- deliverAs: "followUp",
46
- streamingBehavior: "followUp" as any,
47
- } as any);
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
- export function isPrintableChar(data: string): boolean {
2
- return data.length === 1 && data.charCodeAt(0) >= 32 && data.charCodeAt(0) < 127;
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tmustier/pi-files-widget",
3
- "version": "0.1.15",
3
+ "version": "0.1.17",
4
4
  "description": "In-terminal file browser and viewer for Pi.",
5
5
  "license": "MIT",
6
6
  "author": "Thomas Mustier",
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 { isPrintableChar } from "./input-utils";
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
- state.content = loadFileContent(state.file.path, cwd, state.diffMode, hasChanges, width);
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) return;
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 > 0) {
126
- state.scroll = Math.max(0, state.searchMatches[0] - SEARCH_SCROLL_OFFSET);
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
- const prompt = theme.fg("accent", `Comment: ${state.commentText}█`);
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
- try {
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
- if (state.lastRenderWidth !== width || state.content.length === 0) {
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, Key.enter)) {
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 if (isPrintableChar(data)) {
312
- state.commentText += data;
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 if (isPrintableChar(data)) {
326
- state.searchQuery += data;
327
- updateSearchMatches();
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(Math.max(0, state.content.length - VIEWER_SCROLL_MARGIN), state.scroll + 1);
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(Math.max(0, state.content.length - state.height), state.scroll + state.height);
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, "G")) {
386
- state.scroll = Math.max(0, state.content.length - state.height);
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;