@tmustier/pi-files-widget 0.1.15 → 0.1.16

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,17 @@
2
2
 
3
3
  All notable changes to this extension will be documented in this file.
4
4
 
5
+ ## [0.1.16] - 2026-04-19
6
+
7
+ ### Fixed
8
+ - Let `/readfiles` browser search accept `j` and `k` as search text instead of hijacking them for navigation.
9
+ - Fix viewer scrolling so the last lines of a file remain reachable.
10
+ - Restore `G` / `Shift+G` navigation to jump to the bottom of the viewer.
11
+ - Refresh an open viewer when the file changes on disk while `/readfiles` is open.
12
+ - Accept pasted, multi-character, and chunked bracketed-paste input in browser search and the inline comment prompt.
13
+ - Keep viewer search results in sync after live refreshes.
14
+ - Pause live refresh while a line selection or inline comment is active so comments stay anchored to what the user selected.
15
+
5
16
  ## [0.1.14] - 2026-02-03
6
17
 
7
18
  ### Added
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/input-utils.ts CHANGED
@@ -1,3 +1,87 @@
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
+ function sanitizeTextInput(data: string): string {
8
+ const normalized = decodeKittyPrintable(data) ?? data;
9
+ if (!normalized || normalized.includes("\u001b")) {
10
+ return "";
11
+ }
12
+
13
+ return normalized
14
+ .replace(/\r\n?/g, "\n")
15
+ .replace(/\n/g, " ")
16
+ .replace(/\t/g, " ")
17
+ .replace(CONTROL_CHARS, "");
18
+ }
19
+
20
+ function getPendingStartSuffix(data: string): string {
21
+ const maxLength = BRACKETED_PASTE_START.length - 1;
22
+ for (let length = Math.min(data.length, maxLength); length > 0; length--) {
23
+ const suffix = data.slice(-length);
24
+ if (BRACKETED_PASTE_START.startsWith(suffix)) {
25
+ return suffix;
26
+ }
27
+ }
28
+ return "";
29
+ }
30
+
31
+ export interface TextInputBuffer {
32
+ push(data: string): string;
33
+ reset(): void;
34
+ }
35
+
36
+ export function createTextInputBuffer(): TextInputBuffer {
37
+ let isInPaste = false;
38
+ let pasteBuffer = "";
39
+ let pendingStart = "";
40
+
41
+ const push = (data: string): string => {
42
+ if (!data) {
43
+ return "";
44
+ }
45
+
46
+ const combined = pendingStart + data;
47
+ pendingStart = "";
48
+
49
+ if (!isInPaste) {
50
+ const startIndex = combined.indexOf(BRACKETED_PASTE_START);
51
+ if (startIndex === -1) {
52
+ const pendingSuffix = getPendingStartSuffix(combined);
53
+ const completeText = pendingSuffix ? combined.slice(0, combined.length - pendingSuffix.length) : combined;
54
+ pendingStart = pendingSuffix;
55
+ return sanitizeTextInput(completeText);
56
+ }
57
+
58
+ const beforePaste = combined.slice(0, startIndex);
59
+ const afterStart = combined.slice(startIndex + BRACKETED_PASTE_START.length);
60
+ isInPaste = true;
61
+ pasteBuffer = "";
62
+ return sanitizeTextInput(beforePaste) + push(afterStart);
63
+ }
64
+
65
+ pasteBuffer += combined;
66
+ const endIndex = pasteBuffer.indexOf(BRACKETED_PASTE_END);
67
+ if (endIndex === -1) {
68
+ return "";
69
+ }
70
+
71
+ const pastedText = pasteBuffer.slice(0, endIndex);
72
+ const remaining = pasteBuffer.slice(endIndex + BRACKETED_PASTE_END.length);
73
+ isInPaste = false;
74
+ pasteBuffer = "";
75
+
76
+ return sanitizeTextInput(pastedText) + push(remaining);
77
+ };
78
+
79
+ return {
80
+ push,
81
+ reset(): void {
82
+ isInPaste = false;
83
+ pasteBuffer = "";
84
+ pendingStart = "";
85
+ },
86
+ };
3
87
  }
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.16",
4
4
  "description": "In-terminal file browser and viewer for Pi.",
5
5
  "license": "MIT",
6
6
  "author": "Thomas Mustier",
package/viewer.ts CHANGED
@@ -1,6 +1,6 @@
1
1
  import type { Theme } from "@mariozechner/pi-coding-agent";
2
2
  import { Key, matchesKey, truncateToWidth } from "@mariozechner/pi-tui";
3
- import { readFileSync } from "node:fs";
3
+ import { readFileSync, statSync } from "node:fs";
4
4
  import { relative } from "node:path";
5
5
 
6
6
  import {
@@ -8,12 +8,11 @@ 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
14
  import { isUntrackedStatus } from "./utils";
16
- import { isPrintableChar } from "./input-utils";
15
+ import { createTextInputBuffer } from "./input-utils";
17
16
 
18
17
  export interface CommentPayload {
19
18
  relPath: string;
@@ -43,6 +42,7 @@ interface ViewerState {
43
42
  searchMatches: number[];
44
43
  searchIndex: number;
45
44
  lastRenderWidth: number;
45
+ lastLoadedMtimeMs: number | null;
46
46
  height: number;
47
47
  }
48
48
 
@@ -61,6 +61,8 @@ export function createViewer(
61
61
  theme: Theme,
62
62
  requestComment: (payload: CommentPayload, comment: string) => void
63
63
  ): ViewerController {
64
+ const textInput = createTextInputBuffer();
65
+
64
66
  const state: ViewerState = {
65
67
  file: null,
66
68
  content: [],
@@ -75,6 +77,7 @@ export function createViewer(
75
77
  searchMatches: [],
76
78
  searchIndex: 0,
77
79
  lastRenderWidth: 0,
80
+ lastLoadedMtimeMs: null,
78
81
  height: DEFAULT_VIEWER_HEIGHT,
79
82
  };
80
83
 
@@ -94,6 +97,10 @@ export function createViewer(
94
97
  }
95
98
 
96
99
  function setMode(mode: ViewerMode): void {
100
+ if (mode !== state.mode) {
101
+ textInput.reset();
102
+ }
103
+
97
104
  state.mode = mode;
98
105
  if (mode !== "search") resetSearch();
99
106
  if (mode !== "comment") resetComment();
@@ -102,16 +109,59 @@ export function createViewer(
102
109
  }
103
110
  }
104
111
 
112
+ function getMaxScroll(): number {
113
+ return Math.max(0, state.content.length - state.height);
114
+ }
115
+
116
+ function refreshRawContent(): void {
117
+ if (!state.file) return;
118
+
119
+ try {
120
+ const fileStat = statSync(state.file.path);
121
+ state.rawContent = readFileSync(state.file.path, "utf-8");
122
+ state.file.lineCount = state.rawContent.split("\n").length;
123
+ state.lastLoadedMtimeMs = fileStat.mtimeMs;
124
+ } catch {
125
+ state.rawContent = "";
126
+ state.file.lineCount = undefined;
127
+ state.lastLoadedMtimeMs = null;
128
+ }
129
+ }
130
+
131
+ function hasFileChangedOnDisk(): boolean {
132
+ if (!state.file) return false;
133
+
134
+ try {
135
+ return state.lastLoadedMtimeMs === null || statSync(state.file.path).mtimeMs !== state.lastLoadedMtimeMs;
136
+ } catch {
137
+ return state.lastLoadedMtimeMs !== null;
138
+ }
139
+ }
140
+
141
+ function clampScroll(): void {
142
+ state.scroll = Math.min(getMaxScroll(), Math.max(0, state.scroll));
143
+ }
144
+
105
145
  function reloadContent(width: number): void {
106
146
  if (!state.file) return;
147
+ refreshRawContent();
107
148
  const hasChanges = !!state.file.gitStatus;
108
149
  state.content = loadFileContent(state.file.path, cwd, state.diffMode, hasChanges, width);
109
150
  state.lastRenderWidth = width;
151
+ clampScroll();
152
+ if (state.searchQuery) {
153
+ updateSearchMatches({ preserveActiveMatch: true });
154
+ }
110
155
  }
111
156
 
112
- function updateSearchMatches(): void {
157
+ function updateSearchMatches(options: { preserveActiveMatch?: boolean } = {}): void {
158
+ const activeMatch = options.preserveActiveMatch ? state.searchMatches[state.searchIndex] : undefined;
159
+
113
160
  state.searchMatches = [];
114
- if (!state.searchQuery) return;
161
+ if (!state.searchQuery) {
162
+ state.searchIndex = 0;
163
+ return;
164
+ }
115
165
 
116
166
  const q = state.searchQuery.toLowerCase();
117
167
  const rawLines = state.rawContent.split("\n");
@@ -120,11 +170,29 @@ export function createViewer(
120
170
  state.searchMatches.push(i);
121
171
  }
122
172
  }
123
- state.searchIndex = 0;
124
173
 
125
- if (state.searchMatches.length > 0) {
126
- state.scroll = Math.max(0, state.searchMatches[0] - SEARCH_SCROLL_OFFSET);
174
+ if (state.searchMatches.length === 0) {
175
+ state.searchIndex = 0;
176
+ return;
127
177
  }
178
+
179
+ if (activeMatch === undefined) {
180
+ state.searchIndex = 0;
181
+ } else {
182
+ let nearestIndex = 0;
183
+ let nearestDistance = Number.POSITIVE_INFINITY;
184
+ for (let i = 0; i < state.searchMatches.length; i++) {
185
+ const distance = Math.abs(state.searchMatches[i] - activeMatch);
186
+ if (distance < nearestDistance) {
187
+ nearestDistance = distance;
188
+ nearestIndex = i;
189
+ }
190
+ }
191
+ state.searchIndex = nearestIndex;
192
+ }
193
+
194
+ state.scroll = Math.max(0, state.searchMatches[state.searchIndex] - SEARCH_SCROLL_OFFSET);
195
+ clampScroll();
128
196
  }
129
197
 
130
198
  function jumpToNextMatch(direction: 1 | -1): void {
@@ -133,6 +201,7 @@ export function createViewer(
133
201
  if (state.searchIndex < 0) state.searchIndex = state.searchMatches.length - 1;
134
202
  if (state.searchIndex >= state.searchMatches.length) state.searchIndex = 0;
135
203
  state.scroll = Math.max(0, state.searchMatches[state.searchIndex] - SEARCH_SCROLL_OFFSET);
204
+ clampScroll();
136
205
  }
137
206
 
138
207
  function buildCommentPayload(): CommentPayload | null {
@@ -242,14 +311,8 @@ export function createViewer(
242
311
  setMode("normal");
243
312
  state.content = [];
244
313
  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
- }
314
+ state.lastLoadedMtimeMs = null;
315
+ refreshRawContent();
253
316
  },
254
317
 
255
318
  updateFileRef(file: FileNode | null): void {
@@ -259,13 +322,16 @@ export function createViewer(
259
322
  close(): void {
260
323
  state.file = null;
261
324
  state.content = [];
325
+ state.rawContent = "";
326
+ state.lastLoadedMtimeMs = null;
262
327
  setMode("normal");
263
328
  },
264
329
 
265
330
  render(width: number): string[] {
266
331
  if (!state.file) return [];
267
332
 
268
- if (state.lastRenderWidth !== width || state.content.length === 0) {
333
+ const shouldAutoRefresh = state.mode !== "select" && state.mode !== "comment";
334
+ if (state.lastRenderWidth !== width || state.content.length === 0 || (shouldAutoRefresh && hasFileChangedOnDisk())) {
269
335
  reloadContent(width);
270
336
  }
271
337
 
@@ -308,8 +374,11 @@ export function createViewer(
308
374
  setMode("normal");
309
375
  } else if (matchesKey(data, Key.backspace)) {
310
376
  state.commentText = state.commentText.slice(0, -1);
311
- } else if (isPrintableChar(data)) {
312
- state.commentText += data;
377
+ } else {
378
+ const text = textInput.push(data);
379
+ if (text) {
380
+ state.commentText += text;
381
+ }
313
382
  }
314
383
  return { type: "none" };
315
384
  }
@@ -322,9 +391,12 @@ export function createViewer(
322
391
  } else if (matchesKey(data, Key.backspace)) {
323
392
  state.searchQuery = state.searchQuery.slice(0, -1);
324
393
  updateSearchMatches();
325
- } else if (isPrintableChar(data)) {
326
- state.searchQuery += data;
327
- updateSearchMatches();
394
+ } else {
395
+ const text = textInput.push(data);
396
+ if (text) {
397
+ state.searchQuery += text;
398
+ updateSearchMatches();
399
+ }
328
400
  }
329
401
  return { type: "none" };
330
402
  }
@@ -358,7 +430,7 @@ export function createViewer(
358
430
  if (state.mode === "select") {
359
431
  state.selectEnd = Math.min(state.content.length - 1, state.selectEnd + 1);
360
432
  } else {
361
- state.scroll = Math.min(Math.max(0, state.content.length - VIEWER_SCROLL_MARGIN), state.scroll + 1);
433
+ state.scroll = Math.min(getMaxScroll(), state.scroll + 1);
362
434
  }
363
435
  return { type: "none" };
364
436
  }
@@ -371,7 +443,7 @@ export function createViewer(
371
443
  return { type: "none" };
372
444
  }
373
445
  if (matchesKey(data, Key.pageDown)) {
374
- state.scroll = Math.min(Math.max(0, state.content.length - state.height), state.scroll + state.height);
446
+ state.scroll = Math.min(getMaxScroll(), state.scroll + state.height);
375
447
  return { type: "none" };
376
448
  }
377
449
  if (matchesKey(data, Key.pageUp)) {
@@ -382,16 +454,18 @@ export function createViewer(
382
454
  state.scroll = 0;
383
455
  return { type: "none" };
384
456
  }
385
- if (matchesKey(data, "G")) {
386
- state.scroll = Math.max(0, state.content.length - state.height);
457
+ if (matchesKey(data, "shift+g")) {
458
+ state.scroll = getMaxScroll();
387
459
  return { type: "none" };
388
460
  }
389
461
  if (matchesKey(data, "+") || matchesKey(data, "=")) {
390
462
  state.height = Math.min(MAX_VIEWER_HEIGHT, state.height + 5);
463
+ clampScroll();
391
464
  return { type: "none" };
392
465
  }
393
466
  if (matchesKey(data, "-") || matchesKey(data, "_")) {
394
467
  state.height = Math.max(MIN_PANEL_HEIGHT, state.height - 5);
468
+ clampScroll();
395
469
  return { type: "none" };
396
470
  }
397
471
  if (matchesKey(data, "d") && state.mode !== "select" && state.file.gitStatus && !isUntrackedStatus(state.file.gitStatus)) {