pi-web-ui 0.2.12 → 0.2.13

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 CHANGED
@@ -207,6 +207,13 @@ Key design points:
207
207
  reference and the model reads them on demand with its `read` tool, so attaching
208
208
  a 5 MB file costs only a few tokens until the model actually looks at it.
209
209
  Images are always attached as image content.
210
+ - **File preview with line selection.** Click a file name (or its 👁 button) in
211
+ the right panel to open a preview modal with line numbers. Click / drag /
212
+ Shift+click to select a line range, then “添加到对话” to queue it as a `lines`
213
+ attachment — the server inlines only the selected range
214
+ (`<file path=... lines="2-3">`), so you can point the agent at exactly the
215
+ code you mean without dumping the whole file. Preview reads are capped at
216
+ 512 KB and binary files are detected and refused.
210
217
  - **Live tool output.** `bash_execution_update` / `tool_execution_update` events
211
218
  are forwarded as lightweight `tool_delta` messages so terminal output streams
212
219
  in real time; the final output arrives in the toolResult message on the next
@@ -18,6 +18,8 @@ import { loadCommands, saveCommandsFile, TerminalManager, } from "./terminals.js
18
18
  const SNAPSHOT_INTERVAL_MS = 60;
19
19
  const WIDGET_REFRESH_MS = 2000;
20
20
  const WIDGET_WIDTH = 80;
21
+ /** Preview panel cap: only the first 512KB of a file is ever read/sent. */
22
+ const MAX_PREVIEW_BYTES = 512 * 1024;
21
23
  // ---------------------------------------------------------------------------
22
24
  // Web UI context adapter — bridges extension UI calls (setWidget/notify) to the
23
25
  // browser. Extensions like rpiv-todo render a TUI widget via
@@ -244,12 +246,17 @@ const IGNORED_ENTRIES = new Set([
244
246
  "Thumbs.db",
245
247
  ]);
246
248
  function countLines(buf) {
249
+ if (buf.length === 0)
250
+ return 0;
251
+ const hasTrailingNewline = buf[buf.length - 1] === 10; /* \n */
247
252
  let lines = 0;
248
253
  for (let i = 0; i < buf.length; i++) {
249
- if (buf[i] === 10 /* \n */)
254
+ if (buf[i] === 10)
250
255
  lines++;
251
256
  }
252
- return lines + (buf.length > 0 ? 1 : 0);
257
+ // A trailing newline terminates the last line instead of starting an empty
258
+ // one — matches the client preview's split-based line numbering.
259
+ return hasTrailingNewline ? lines : lines + 1;
253
260
  }
254
261
  function extractPartialText(partial) {
255
262
  const content = partial
@@ -959,7 +966,8 @@ export class ClientSession {
959
966
  * Text files are size-aware: small files are inlined into the message so the
960
967
  * model sees them immediately; large files are passed as a <file path="...">
961
968
  * reference and the model reads them on demand with its read tool (which has
962
- * built-in truncation). Images are always passed as image content.
969
+ * built-in truncation). Images are always passed as image content. Mode
970
+ * "lines" inlines only a 1-based inclusive line range of the file.
963
971
  */
964
972
  async buildAttachmentMessages(attachments) {
965
973
  if (!attachments || attachments.length === 0)
@@ -990,6 +998,8 @@ export class ClientSession {
990
998
  ".svg": "image/svg+xml",
991
999
  };
992
1000
  const out = [];
1001
+ /** Cap for reading a file in "lines" mode (selected slice is inlined). */
1002
+ const MAX_LINES_READ_BYTES = 2 * 1024 * 1024;
993
1003
  for (const att of attachments) {
994
1004
  const abs = resolve(root, att.path);
995
1005
  const rel = relative(root, abs);
@@ -1104,6 +1114,79 @@ export class ClientSession {
1104
1114
  out.push(makeReference());
1105
1115
  continue;
1106
1116
  }
1117
+ // Line-range mode: inline only the selected 1-based inclusive range.
1118
+ // Reading is capped so a huge file can't exhaust memory even though
1119
+ // the selected slice is small.
1120
+ if (att.mode === "lines") {
1121
+ const range = att.lines;
1122
+ if (!range || range.start < 1 || range.end < range.start) {
1123
+ this.emit({
1124
+ type: "notice",
1125
+ level: "warning",
1126
+ text: `行范围无效,已改为仅引用:${att.path}`,
1127
+ });
1128
+ out.push(makeReference());
1129
+ continue;
1130
+ }
1131
+ if (stat.size > MAX_LINES_READ_BYTES) {
1132
+ this.emit({
1133
+ type: "notice",
1134
+ level: "warning",
1135
+ text: `文件过大,已改为仅引用:${att.path}`,
1136
+ });
1137
+ out.push(makeReference());
1138
+ continue;
1139
+ }
1140
+ const buf = await fs.readFile(abs);
1141
+ if (buf.includes(0)) {
1142
+ this.emit({
1143
+ type: "notice",
1144
+ level: "warning",
1145
+ text: `二进制文件已改为仅引用:${att.path}`,
1146
+ });
1147
+ out.push(makeReference());
1148
+ continue;
1149
+ }
1150
+ const parts = buf.toString("utf8").split("\n");
1151
+ // A trailing newline yields an empty phantom line — drop it so line
1152
+ // numbers match the preview panel.
1153
+ if (parts.length > 0 && parts[parts.length - 1] === "")
1154
+ parts.pop();
1155
+ const start = Math.min(range.start, parts.length);
1156
+ const end = Math.min(range.end, parts.length);
1157
+ if (start < 1 || end < start) {
1158
+ this.emit({
1159
+ type: "notice",
1160
+ level: "warning",
1161
+ text: `选中行超出文件范围,已改为仅引用:${att.path}`,
1162
+ });
1163
+ out.push(makeReference());
1164
+ continue;
1165
+ }
1166
+ const selected = parts.slice(start - 1, end).join("\n");
1167
+ out.push({
1168
+ message: {
1169
+ customType: "file",
1170
+ content: [
1171
+ {
1172
+ type: "text",
1173
+ text: `\n<file path="${rel}" lines="${start}-${end}">\n\`\`\`\n${selected}\n\`\`\`\n</file>`,
1174
+ },
1175
+ ],
1176
+ display: true,
1177
+ details: {
1178
+ name,
1179
+ path: rel,
1180
+ mode: "lines",
1181
+ size: stat.size,
1182
+ lines: end - start + 1,
1183
+ startLine: start,
1184
+ endLine: end,
1185
+ },
1186
+ },
1187
+ });
1188
+ continue;
1189
+ }
1107
1190
  // Forced inline has a hard cap to protect the model context.
1108
1191
  if (att.mode === "inline") {
1109
1192
  if (stat.size > MAX_INLINE_BYTES) {
@@ -1288,6 +1371,75 @@ export class ClientSession {
1288
1371
  });
1289
1372
  }
1290
1373
  }
1374
+ /** Read a workspace file for the preview panel (size-capped, binary-safe). */
1375
+ async readFile(relPath) {
1376
+ try {
1377
+ const fs = await import("node:fs/promises");
1378
+ const { resolve, sep, relative } = await import("node:path");
1379
+ const root = resolve(this.cwd);
1380
+ const abs = resolve(root, relPath);
1381
+ const rel = relative(root, abs);
1382
+ if (rel.startsWith("..") || rel.includes(`${sep}..`)) {
1383
+ this.emit({
1384
+ type: "notice",
1385
+ level: "warning",
1386
+ text: `路径超出工作区:${relPath}`,
1387
+ });
1388
+ return;
1389
+ }
1390
+ const stat = await fs.stat(abs);
1391
+ if (!stat.isFile()) {
1392
+ this.emit({
1393
+ type: "notice",
1394
+ level: "warning",
1395
+ text: `不是文件:${relPath}`,
1396
+ });
1397
+ return;
1398
+ }
1399
+ // Read only the first MAX_PREVIEW_BYTES so huge files can't exhaust
1400
+ // memory or flood the socket.
1401
+ const handle = await fs.open(abs, "r");
1402
+ try {
1403
+ const buf = Buffer.alloc(Math.min(stat.size, MAX_PREVIEW_BYTES));
1404
+ const { bytesRead } = await handle.read(buf, 0, buf.length, 0);
1405
+ const data = buf.subarray(0, bytesRead);
1406
+ const name = relPath.split("/").pop() ?? relPath;
1407
+ if (data.includes(0)) {
1408
+ this.emit({
1409
+ type: "file_content",
1410
+ path: rel,
1411
+ name,
1412
+ text: "",
1413
+ truncated: false,
1414
+ binary: true,
1415
+ lines: 0,
1416
+ size: stat.size,
1417
+ });
1418
+ return;
1419
+ }
1420
+ this.emit({
1421
+ type: "file_content",
1422
+ path: rel,
1423
+ name,
1424
+ text: data.toString("utf8"),
1425
+ truncated: bytesRead < stat.size,
1426
+ binary: false,
1427
+ lines: countLines(data),
1428
+ size: stat.size,
1429
+ });
1430
+ }
1431
+ finally {
1432
+ await handle.close();
1433
+ }
1434
+ }
1435
+ catch (err) {
1436
+ this.emit({
1437
+ type: "notice",
1438
+ level: "error",
1439
+ text: `读取文件失败:${err.message}`,
1440
+ });
1441
+ }
1442
+ }
1291
1443
  async cycleModel() {
1292
1444
  try {
1293
1445
  await this.session.cycleModel();
@@ -104,6 +104,9 @@ wss.on("connection", (ws) => {
104
104
  case "list_files":
105
105
  void cs.listFiles(msg.path);
106
106
  break;
107
+ case "read_file":
108
+ void cs.readFile(msg.path);
109
+ break;
107
110
  case "list_models":
108
111
  void cs.listModels();
109
112
  break;
@@ -121,7 +121,10 @@ const SHELL_ARGS = isWindows ? [] : ["-i"];
121
121
  * Default a UTF-8 locale so multibyte text round-trips.
122
122
  */
123
123
  function shellEnv() {
124
- const env = { ...process.env, TERM: "xterm-256color" };
124
+ const env = {
125
+ ...process.env,
126
+ TERM: "xterm-256color",
127
+ };
125
128
  if (!env.LANG && !env.LC_ALL)
126
129
  env.LANG = "en_US.UTF-8";
127
130
  return env;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-web-ui",
3
- "version": "0.2.12",
3
+ "version": "0.2.13",
4
4
  "description": "Web chat interface for the pi coding agent, powered by the pi SDK (@earendil-works/pi-coding-agent) — one-command run, Docker/systemd/launchd deployable",
5
5
  "license": "MIT",
6
6
  "type": "module",