pi-web-ui 0.28.2 → 0.29.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (40) hide show
  1. package/LICENSE +21 -21
  2. package/README.md +295 -295
  3. package/README.zh-CN.md +279 -279
  4. package/bin/pi-web-ui.mjs +0 -0
  5. package/deploy/com.xingshuyin.pi-web-ui.plist +48 -48
  6. package/deploy/nginx-subpath.conf +88 -88
  7. package/deploy/pi-web-ui-task.xml +71 -71
  8. package/deploy/pi-web-ui.service +31 -31
  9. package/dist/server/agent-service.js +408 -3851
  10. package/dist/server/attachments.js +621 -0
  11. package/dist/server/bg-servers.js +138 -0
  12. package/dist/server/client-state.js +148 -0
  13. package/dist/server/files-service.js +633 -0
  14. package/dist/server/goal-service.js +869 -0
  15. package/dist/server/index.js +144 -8
  16. package/dist/server/model-admin.js +727 -0
  17. package/dist/server/process-utils.js +86 -0
  18. package/dist/server/protocol-version.js +11 -0
  19. package/dist/server/scm.js +298 -0
  20. package/dist/server/settings-service.js +268 -0
  21. package/dist/server/slash-commands.js +245 -0
  22. package/dist/server/terminals.js +98 -0
  23. package/dist/server/text-sniff.js +268 -0
  24. package/dist/server/uploads.js +107 -0
  25. package/dist/server/webui-context.js +208 -0
  26. package/extensions/webui.ts +192 -192
  27. package/package.json +94 -87
  28. package/themes/light.css +6318 -6318
  29. package/web/dist/assets/TerminalPanel-6GBZ9nXN.css +32 -0
  30. package/web/dist/assets/TerminalPanel-B-bsYqea.js +2 -0
  31. package/web/dist/assets/index-BsrqFaSZ.js +13 -0
  32. package/web/dist/assets/index-Dsb8Bak1.css +10 -0
  33. package/web/dist/assets/markdown-DRBrS2Nf.js +51 -0
  34. package/web/dist/assets/react-C9ovnpIm.js +24 -0
  35. package/web/dist/assets/xterm-D1D2FVe3.js +38 -0
  36. package/web/dist/favicon.svg +8 -8
  37. package/web/dist/index.html +17 -15
  38. package/web/public/favicon.svg +8 -8
  39. package/web/dist/assets/index-BnDkdKFN.css +0 -41
  40. package/web/dist/assets/index-DmmSVSzk.js +0 -129
@@ -0,0 +1,245 @@
1
+ /** Slash commands implemented natively by the web server (the pi CLI's built-in
2
+ * interactive commands like /model and /new are NOT handled by the SDK's
3
+ * prompt() — without this they'd be sent to the model as plain text). Keep in
4
+ * sync with exec(). */
5
+ export const NATIVE_COMMANDS = [
6
+ { name: "new", description: "新建对话", descriptionEn: "New chat" },
7
+ { name: "model", description: "切换模型", descriptionEn: "Switch model", argumentHint: "[名称]", argumentHintEn: "[name]" },
8
+ { name: "compact", description: "压缩上下文", descriptionEn: "Compact context", argumentHint: "[说明]", argumentHintEn: "[instructions]" },
9
+ { name: "cwd", description: "切换工作目录", descriptionEn: "Switch workspace", argumentHint: "<路径>", argumentHintEn: "<path>" },
10
+ {
11
+ name: "thinking",
12
+ description: "设置思考强度",
13
+ descriptionEn: "Set thinking level",
14
+ argumentHint: "<off|low|medium|high|xhigh|max>",
15
+ argumentHintEn: "<off|low|medium|high|xhigh|max>",
16
+ },
17
+ { name: "resume", description: "刷新会话列表", descriptionEn: "Refresh session list" },
18
+ { name: "reload", description: "重新加载扩展、技能与模板", descriptionEn: "Reload extensions, skills & templates" },
19
+ { name: "help", description: "显示全部命令", descriptionEn: "Show all commands" },
20
+ { name: "copy", description: "复制上一条助手回复", descriptionEn: "Copy last assistant reply" },
21
+ { name: "pi-web-ui:quit", description: "退出服务", descriptionEn: "Quit server (supervisor will restart)" },
22
+ ];
23
+ /** Parse a prompt into "/command args" — returns null when it isn't one. */
24
+ export function parseSlash(text) {
25
+ const trimmed = text.trim();
26
+ if (!trimmed.startsWith("/"))
27
+ return null;
28
+ const m = trimmed.match(/^\/([^\s]+)\s*([\s\S]*)$/);
29
+ if (!m || !m[1])
30
+ return null;
31
+ return { name: m[1], args: m[2].trim() };
32
+ }
33
+ export class SlashCommandsService {
34
+ host;
35
+ constructor(host) {
36
+ this.host = host;
37
+ }
38
+ /**
39
+ * Catalog of slash commands for the chat input: web-native builtins first,
40
+ * then the SDK's invokable commands for the ACTIVE conversation (extension
41
+ * commands, prompt templates, skills) — the same set the SDK expands when a
42
+ * prompt text starts with "/" (see AgentSession.prompt).
43
+ */
44
+ async push() {
45
+ const commands = [];
46
+ const seen = new Set();
47
+ for (const c of NATIVE_COMMANDS) {
48
+ commands.push({ ...c, source: "builtin" });
49
+ seen.add(c.name);
50
+ }
51
+ try {
52
+ const s = this.host.getSession();
53
+ // Extension commands — the SDK already suffixes collisions with builtin
54
+ // names ("new:2"), and those still reach the SDK since exec() only
55
+ // intercepts the exact native names.
56
+ for (const cmd of s.extensionRunner.getRegisteredCommands()) {
57
+ if (seen.has(cmd.invocationName))
58
+ continue;
59
+ commands.push({
60
+ name: cmd.invocationName,
61
+ description: cmd.description,
62
+ source: "extension",
63
+ });
64
+ seen.add(cmd.invocationName);
65
+ }
66
+ // Prompt templates: /templatename args
67
+ for (const t of s.promptTemplates) {
68
+ if (seen.has(t.name))
69
+ continue;
70
+ commands.push({
71
+ name: t.name,
72
+ description: t.description,
73
+ source: "prompt",
74
+ });
75
+ seen.add(t.name);
76
+ }
77
+ // Skills: /skill:name args
78
+ for (const skill of s.resourceLoader.getSkills().skills) {
79
+ const name = `skill:${skill.name}`;
80
+ if (seen.has(name))
81
+ continue;
82
+ commands.push({
83
+ name,
84
+ description: skill.description,
85
+ source: "skill",
86
+ });
87
+ }
88
+ }
89
+ catch {
90
+ // Session not ready yet — native-only catalog still serves the picker.
91
+ }
92
+ this.host.emit({ type: "slash_commands", commands });
93
+ }
94
+ /** Run a native slash command (see NATIVE_COMMANDS). Returns false when the
95
+ * name is not a native command (the prompt falls through to the SDK). */
96
+ async exec(name, args) {
97
+ switch (name) {
98
+ case "new":
99
+ await this.host.newChat();
100
+ return true;
101
+ case "model": {
102
+ if (!args) {
103
+ const current = this.host.getSession().model;
104
+ this.host.emit({
105
+ type: "notice",
106
+ level: "info",
107
+ text: current
108
+ ? `当前模型:${current.name}(${current.provider}/${current.id})。用法:/model <名称>`
109
+ : `用法:/model <名称>`,
110
+ });
111
+ return true;
112
+ }
113
+ const query = args.toLowerCase();
114
+ const available = await this.host.getSession().modelRuntime.getAvailable();
115
+ // Prefer an exact "provider/id" match, else id/name substring.
116
+ const exact = available.find((m) => m.provider + "/" + m.id === args.trim());
117
+ const matches = exact
118
+ ? [exact]
119
+ : available.filter((m) => m.id.toLowerCase().includes(query) ||
120
+ m.name.toLowerCase().includes(query) ||
121
+ m.provider.toLowerCase().includes(query));
122
+ if (matches.length === 0) {
123
+ this.host.emit({
124
+ type: "notice",
125
+ level: "error",
126
+ text: `没有匹配到模型:${args}(可用模型见顶栏模型列表)`,
127
+ });
128
+ return true;
129
+ }
130
+ const pick = matches[0];
131
+ if (matches.length > 1) {
132
+ this.host.emit({
133
+ type: "notice",
134
+ level: "warning",
135
+ text: `找到 ${matches.length} 个匹配模型,已选用:${pick.name}(精确匹配请用 provider/id)`,
136
+ });
137
+ }
138
+ await this.host.setModel(`${pick.provider}/${pick.id}`);
139
+ return true;
140
+ }
141
+ case "compact":
142
+ try {
143
+ await this.host.getSession().compact(args || undefined);
144
+ }
145
+ catch (err) {
146
+ this.host.emit({
147
+ type: "notice",
148
+ level: "error",
149
+ text: `压缩上下文失败:${err.message}`,
150
+ });
151
+ }
152
+ return true;
153
+ case "cwd":
154
+ if (!args) {
155
+ this.host.emit({
156
+ type: "notice",
157
+ level: "info",
158
+ text: `当前工作目录:${this.host.cwd()}。用法:/cwd <路径>`,
159
+ });
160
+ }
161
+ else {
162
+ await this.host.setCwd(args);
163
+ }
164
+ return true;
165
+ case "thinking": {
166
+ const ALIAS = {
167
+ off: "off",
168
+ minimal: "minimal",
169
+ low: "low",
170
+ medium: "medium",
171
+ high: "high",
172
+ xhigh: "xhigh",
173
+ max: "max",
174
+ 关闭: "off",
175
+ 极简: "minimal",
176
+ 低: "low",
177
+ 中: "medium",
178
+ 高: "high",
179
+ 极高: "xhigh",
180
+ 最大: "max",
181
+ };
182
+ const level = ALIAS[args.trim().toLowerCase()];
183
+ if (!level) {
184
+ this.host.emit({
185
+ type: "notice",
186
+ level: "error",
187
+ text: `无效的思考强度:${args || "(空)"}。可用:off / minimal / low / medium / high / xhigh / max`,
188
+ });
189
+ return true;
190
+ }
191
+ this.host.setThinking(level);
192
+ return true;
193
+ }
194
+ case "resume":
195
+ await this.host.refreshSessions();
196
+ this.host.emit({
197
+ type: "notice",
198
+ level: "info",
199
+ text: "会话列表已刷新,请在左侧「历史对话」中选择",
200
+ });
201
+ return true;
202
+ case "reload":
203
+ try {
204
+ // Re-discovers extensions / skills / prompt templates from disk and
205
+ // re-pushes the picker catalog (the CLI's /reload semantics).
206
+ await this.host.getSession().reload();
207
+ await this.push();
208
+ this.host.emit({
209
+ type: "notice",
210
+ level: "info",
211
+ text: "已重新加载扩展、技能与提示模板",
212
+ });
213
+ }
214
+ catch (err) {
215
+ this.host.emit({
216
+ type: "notice",
217
+ level: "error",
218
+ text: `重新加载失败:${err.message}`,
219
+ });
220
+ }
221
+ return true;
222
+ case "pi-web-ui:quit": {
223
+ this.host.emit({
224
+ type: "notice",
225
+ level: "info",
226
+ text: "正在退出 pi-web-ui… supervisor 将自动重启服务",
227
+ });
228
+ setTimeout(() => {
229
+ const didSchedule = this.host.onQuit?.() ?? false;
230
+ if (!didSchedule) {
231
+ setTimeout(() => process.exit(0), 100);
232
+ }
233
+ }, 300);
234
+ return true;
235
+ }
236
+ case "help":
237
+ case "copy":
238
+ // Client-side UI actions — the client handles them before sending;
239
+ // swallow here so the SDK never sees them as plain prompt text.
240
+ return true;
241
+ default:
242
+ return false;
243
+ }
244
+ }
245
+ }
@@ -22,6 +22,8 @@ import { dirname, isAbsolute, join, relative, resolve, sep } from "node:path";
22
22
  // module itself for details).
23
23
  import "./patch-node-pty.js";
24
24
  import { spawn } from "node-pty";
25
+ import { defineTool, } from "@earendil-works/pi-coding-agent";
26
+ import { Type } from "typebox";
25
27
  /** Location of the command list for a project: <workspaceRoot>/.pi/commands.json */
26
28
  export function commandsFilePath(workspaceRoot) {
27
29
  return join(workspaceRoot, ".pi", "commands.json");
@@ -741,3 +743,99 @@ export class TerminalManager {
741
743
  this.emitList();
742
744
  }
743
745
  }
746
+ /** Build the agent-facing persistent terminal tools for one conversation. */
747
+ export function makePersistentTerminalTools(terminals, cwd) {
748
+ const result = (text, details = {}) => ({ content: [{ type: "text", text }], details });
749
+ const failIf = (error) => {
750
+ if (error)
751
+ throw new Error(error);
752
+ };
753
+ return [
754
+ defineTool({
755
+ name: "terminal_create",
756
+ label: "Create terminal",
757
+ description: "Create a named persistent interactive PTY in the current workspace. Use terminal_input or terminal_key to interact with it and terminal_read to inspect incremental output.",
758
+ promptSnippet: "create persistent interactive PTY terminals",
759
+ parameters: Type.Object({
760
+ terminalId: Type.String({ description: "Stable terminal name" }),
761
+ cwd: Type.Optional(Type.String({ description: "Workspace-relative directory" })),
762
+ cols: Type.Optional(Type.Integer({ minimum: 2, maximum: 500 })),
763
+ rows: Type.Optional(Type.Integer({ minimum: 2, maximum: 200 })),
764
+ }),
765
+ execute: async (_id, p) => {
766
+ const info = terminals.create(p.terminalId, p.cwd ?? cwd, p.cols ?? 120, p.rows ?? 40, cwd, p.terminalId);
767
+ if (!info)
768
+ throw new Error(`创建终端失败:${p.terminalId}`);
769
+ return result(`终端已创建:${JSON.stringify(info)}`, info);
770
+ },
771
+ }),
772
+ defineTool({
773
+ name: "terminal_list",
774
+ label: "List terminals",
775
+ description: "List all persistent PTY terminals owned by this conversation.",
776
+ promptSnippet: "list persistent terminals",
777
+ parameters: Type.Object({}),
778
+ execute: async () => result(JSON.stringify(terminals.list()), terminals.list()),
779
+ }),
780
+ defineTool({
781
+ name: "terminal_close",
782
+ label: "Close terminal",
783
+ description: "Close a persistent PTY and terminate its process tree.",
784
+ parameters: Type.Object({ terminalId: Type.String() }),
785
+ execute: async (_id, p) => {
786
+ if (!terminals.has(p.terminalId))
787
+ throw new Error(`终端不存在:${p.terminalId}`);
788
+ terminals.kill(p.terminalId);
789
+ return result(`终端已关闭:${p.terminalId}`);
790
+ },
791
+ }),
792
+ defineTool({
793
+ name: "terminal_input",
794
+ label: "Send terminal input",
795
+ description: "Send arbitrary text to a persistent PTY. Include newline when a command should be submitted.",
796
+ parameters: Type.Object({ terminalId: Type.String(), data: Type.String() }),
797
+ execute: async (_id, p) => {
798
+ failIf(terminals.inputChecked(p.terminalId, p.data));
799
+ return result(`已发送 ${p.data.length} 个字符到 ${p.terminalId}`);
800
+ },
801
+ }),
802
+ defineTool({
803
+ name: "terminal_key",
804
+ label: "Send terminal key",
805
+ description: "Send Enter, Tab, arrows, function keys, or Ctrl/Alt combinations to a persistent PTY.",
806
+ parameters: Type.Object({
807
+ terminalId: Type.String(),
808
+ key: Type.String({ description: "Enter, Tab, ArrowUp, c, etc." }),
809
+ modifiers: Type.Optional(Type.Object({
810
+ ctrl: Type.Optional(Type.Boolean()),
811
+ alt: Type.Optional(Type.Boolean()),
812
+ shift: Type.Optional(Type.Boolean()),
813
+ })),
814
+ }),
815
+ execute: async (_id, p) => {
816
+ failIf(terminals.key(p.terminalId, p.key, p.modifiers));
817
+ return result(`已发送按键 ${p.key} 到 ${p.terminalId}`);
818
+ },
819
+ }),
820
+ defineTool({
821
+ name: "terminal_read",
822
+ label: "Read terminal output",
823
+ description: "Read incremental output from a persistent PTY. Keep the returned cursor and pass it on the next read; optionally wait for new output or process exit.",
824
+ parameters: Type.Object({
825
+ terminalId: Type.String(),
826
+ cursor: Type.Optional(Type.Integer({ minimum: 0 })),
827
+ maxBytes: Type.Optional(Type.Integer({ minimum: 1, maximum: 100000 })),
828
+ waitMs: Type.Optional(Type.Integer({ minimum: 0, maximum: 120000 })),
829
+ }),
830
+ execute: async (_id, p, signal) => {
831
+ const cursor = p.cursor ?? 0;
832
+ if (p.waitMs)
833
+ await terminals.waitForOutput(p.terminalId, cursor, p.waitMs, signal);
834
+ const read = terminals.read(p.terminalId, cursor, p.maxBytes ?? 20000);
835
+ if (!read)
836
+ throw new Error(`终端不存在:${p.terminalId}`);
837
+ return result(JSON.stringify(read), read);
838
+ },
839
+ }),
840
+ ];
841
+ }
@@ -0,0 +1,268 @@
1
+ /**
2
+ * text-sniff — 文件预览相关的纯函数:扩展名分类、内容嗅探、文本解码
3
+ * (UTF-8 → GBK → latin1 回退)、十六进制视图、行数统计。
4
+ *
5
+ * 全部无副作用、不碰 fs —— 便于单元测试(tests/unit/text-sniff.test.ts)。
6
+ * 从 agent-service.ts 抽出,行为保持不变。
7
+ */
8
+ const PREVIEW_IMAGE_EXTS = new Set([
9
+ "png",
10
+ "jpg",
11
+ "jpeg",
12
+ "gif",
13
+ "webp",
14
+ "svg",
15
+ "bmp",
16
+ "ico",
17
+ "avif",
18
+ "jfif",
19
+ "tif",
20
+ "tiff",
21
+ ]);
22
+ const PREVIEW_VIDEO_EXTS = new Set([
23
+ "mp4",
24
+ "webm",
25
+ "mov",
26
+ "mkv",
27
+ "avi",
28
+ "m4v",
29
+ "ogv",
30
+ "mpg",
31
+ "mpeg",
32
+ "wmv",
33
+ "flv",
34
+ ]);
35
+ const PREVIEW_TEXT_EXTS = new Set([
36
+ // code
37
+ "ts",
38
+ "tsx",
39
+ "js",
40
+ "jsx",
41
+ "mjs",
42
+ "cjs",
43
+ "jsm",
44
+ "es6",
45
+ "vue",
46
+ "svelte",
47
+ "py",
48
+ "pyw",
49
+ "ipynb",
50
+ "go",
51
+ "rs",
52
+ "c",
53
+ "h",
54
+ "cpp",
55
+ "hpp",
56
+ "cc",
57
+ "cxx",
58
+ "hh",
59
+ "csh",
60
+ "java",
61
+ "kt",
62
+ "kts",
63
+ "scala",
64
+ "sc",
65
+ "cs",
66
+ "fs",
67
+ "fsx",
68
+ "fsi",
69
+ "sh",
70
+ "bash",
71
+ "zsh",
72
+ "fish",
73
+ "bat",
74
+ "cmd",
75
+ "ps1",
76
+ "psd1",
77
+ "psm1",
78
+ "rb",
79
+ "php",
80
+ "pl",
81
+ "pm",
82
+ "tcl",
83
+ "lua",
84
+ "r",
85
+ "rmd",
86
+ "sql",
87
+ "swift",
88
+ "dart",
89
+ "groovy",
90
+ "gradle",
91
+ "tf",
92
+ "tfvars",
93
+ "hcl",
94
+ "nim",
95
+ "zig",
96
+ "v",
97
+ "vala",
98
+ "d",
99
+ "clj",
100
+ "cljs",
101
+ "cljc",
102
+ "edn",
103
+ "ex",
104
+ "exs",
105
+ "erl",
106
+ "hrl",
107
+ "ml",
108
+ "mli",
109
+ // markup / config / data
110
+ "json",
111
+ "jsonc",
112
+ "json5",
113
+ "jsonl",
114
+ "md",
115
+ "mdx",
116
+ "markdown",
117
+ "html",
118
+ "htm",
119
+ "xhtml",
120
+ "css",
121
+ "scss",
122
+ "sass",
123
+ "less",
124
+ "styl",
125
+ "xml",
126
+ "dtd",
127
+ "yaml",
128
+ "yml",
129
+ "toml",
130
+ "ini",
131
+ "cfg",
132
+ "conf",
133
+ "properties",
134
+ "env",
135
+ "log",
136
+ "txt",
137
+ "text",
138
+ "csv",
139
+ "tsv",
140
+ "lock",
141
+ "sqlite",
142
+ "graphql",
143
+ "gql",
144
+ "proto",
145
+ "prisma",
146
+ "asm",
147
+ "s",
148
+ ]);
149
+ /**
150
+ * Classify a file name into its preview category. Files with no extension
151
+ * (README, Makefile, .gitignore, …) are treated as text. Everything not in an
152
+ * allowlist (exe, jar, dll, zip, …) is "none" — never previewed.
153
+ */
154
+ export function previewKind(name) {
155
+ const dot = name.lastIndexOf(".");
156
+ // A leading dot with nothing after it (.gitignore, .env) counts as no ext.
157
+ const ext = dot > 0 ? name.slice(dot + 1).toLowerCase() : "";
158
+ if (PREVIEW_IMAGE_EXTS.has(ext))
159
+ return "image";
160
+ if (PREVIEW_VIDEO_EXTS.has(ext))
161
+ return "video";
162
+ if (ext === "" || PREVIEW_TEXT_EXTS.has(ext))
163
+ return "text";
164
+ return "none";
165
+ }
166
+ /**
167
+ * Content sniff for the preview: any data that has no NUL bytes and no
168
+ * meaningful control-char ratio is treated as text — so files with unknown
169
+ * or absent extensions (jsonl, .log.1, …) still open as text. NULs catch
170
+ * zip/sqlite/png/… even when the extension claims text.
171
+ */
172
+ export function looksLikeText(buf) {
173
+ if (buf.length === 0)
174
+ return true;
175
+ if (buf.includes(0))
176
+ return false;
177
+ const text = buf.toString("utf8");
178
+ let control = 0;
179
+ for (const ch of text) {
180
+ const c = ch.charCodeAt(0);
181
+ // Keep \t \n \r \f (and \b); everything else < 0x20 is binary-ish.
182
+ if (c < 0x20 && c !== 9 && c !== 10 && c !== 12 && c !== 13)
183
+ control++;
184
+ }
185
+ return control / Math.max(text.length, 1) < 0.02;
186
+ }
187
+ /** Decode bytes: strict UTF-8 first, falling back to GBK (Windows legacy
188
+ * Chinese files), then latin1 as a last resort — so previews and inline
189
+ * attachments never show mojibake for GBK/GB2312 encoded files. */
190
+ export function decodeText(buf) {
191
+ try {
192
+ return new TextDecoder("utf-8", { fatal: true }).decode(buf);
193
+ }
194
+ catch {
195
+ try {
196
+ return new TextDecoder("gbk").decode(buf);
197
+ }
198
+ catch {
199
+ return buf.toString("latin1");
200
+ }
201
+ }
202
+ }
203
+ /** Sniff an image MIME type from magic bytes (extension is only a hint).
204
+ * Returns null when the bytes don't look like a known raster format —
205
+ * callers keep such files as plain path references. */
206
+ export function sniffImageMime(buf, ext) {
207
+ if (buf.length >= 8 &&
208
+ buf[0] === 0x89 &&
209
+ buf[1] === 0x50 &&
210
+ buf[2] === 0x4e &&
211
+ buf[3] === 0x47) {
212
+ return "image/png";
213
+ }
214
+ if (buf.length >= 3 && buf[0] === 0xff && buf[1] === 0xd8 && buf[2] === 0xff) {
215
+ return "image/jpeg";
216
+ }
217
+ const head = buf.slice(0, 6).toString("ascii");
218
+ if (head === "GIF87a" || head === "GIF89a")
219
+ return "image/gif";
220
+ if (buf.length >= 12 &&
221
+ buf.slice(0, 4).toString("ascii") === "RIFF" &&
222
+ buf.slice(8, 12).toString("ascii") === "WEBP") {
223
+ return "image/webp";
224
+ }
225
+ if (buf.length >= 2 && buf[0] === 0x42 && buf[1] === 0x4d)
226
+ return "image/bmp";
227
+ // Unknown but raster-looking extension — trust the extension so existing
228
+ // image attachments keep working.
229
+ const known = {
230
+ ".png": "image/png",
231
+ ".jpg": "image/jpeg",
232
+ ".jpeg": "image/jpeg",
233
+ ".gif": "image/gif",
234
+ ".webp": "image/webp",
235
+ ".bmp": "image/bmp",
236
+ };
237
+ return known[ext] ?? null;
238
+ }
239
+ /** First few KB of binary data as a classic hex + ASCII dump (preview only). */
240
+ export function hexDump(buf, maxBytes = 4096) {
241
+ const data = buf.subarray(0, Math.min(buf.length, maxBytes));
242
+ const rows = [];
243
+ for (let off = 0; off < data.length; off += 16) {
244
+ const chunk = data.subarray(off, off + 16);
245
+ const hex = [...chunk]
246
+ .map((b) => b.toString(16).padStart(2, "0"))
247
+ .join(" ");
248
+ const ascii = [...chunk]
249
+ .map((b) => (b >= 0x20 && b < 0x7f ? String.fromCharCode(b) : "."))
250
+ .join("");
251
+ rows.push(`${off.toString(16).padStart(8, "0")} ${hex.padEnd(47, " ")} ${ascii}`);
252
+ }
253
+ return rows.join("\n");
254
+ }
255
+ /** Count lines in a buffer; a trailing newline terminates the last line
256
+ * instead of starting an empty one — matches the client preview's
257
+ * split-based line numbering. */
258
+ export function countLines(buf) {
259
+ if (buf.length === 0)
260
+ return 0;
261
+ const hasTrailingNewline = buf[buf.length - 1] === 10; /* \n */
262
+ let lines = 0;
263
+ for (let i = 0; i < buf.length; i++) {
264
+ if (buf[i] === 10)
265
+ lines++;
266
+ }
267
+ return hasTrailingNewline ? lines : lines + 1;
268
+ }