@stevecastaneda/paseo-progress 1.11.0 → 1.12.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.
package/server/open.ts CHANGED
@@ -3,9 +3,10 @@ import { execFile } from "node:child_process";
3
3
  import { existsSync } from "node:fs";
4
4
  import { open, readdir, readFile, realpath, stat } from "node:fs/promises";
5
5
  import { homedir, hostname } from "node:os";
6
- import { delimiter, dirname, join, relative, resolve } from "node:path";
6
+ import nodePath, { delimiter, dirname, join, relative, resolve } from "node:path";
7
7
  import { promisify } from "node:util";
8
- import { findAttachment, type FolderEntry, imageMimeType, previewKind, splitRef, videoMimeType } from "../shared/attachments.ts";
8
+ import { findAttachment, type FolderEntry, imageMimeType, previewKind, splitRef, textFormat, videoMimeType } from "../shared/attachments.ts";
9
+ import { prepareMarkdown, previewText } from "../shared/markdown.ts";
9
10
  import { readProgress } from "./progress-file.ts";
10
11
  import { findRoot, outside } from "./paths.ts";
11
12
  import { findStore } from "./store.ts";
@@ -53,7 +54,7 @@ export function hostOpener(platform: NodeJS.Platform, env: NodeJS.ProcessEnv): O
53
54
  // A deliverable or question attachment the agent recorded, found by reference,
54
55
  // so the panel can never reach an arbitrary path. Only files inside the
55
56
  // worktree resolve.
56
- async function resolveAttachment(directory: string, ref: string, verb: string): Promise<{ path: string; target: string; root: string }> {
57
+ async function resolveAttachment(directory: string, ref: string, verb: string): Promise<{ path: string; realTarget: string; realRoot: string; worktree: string }> {
57
58
  const { base, inner } = splitRef(ref);
58
59
  const worktree = findRoot(directory);
59
60
  const { dashboard } = await readProgress((await findStore(worktree)).directory);
@@ -61,18 +62,18 @@ async function resolveAttachment(directory: string, ref: string, verb: string):
61
62
  if (!folder) throw new Error(`No local ${base.startsWith("Q") ? "attachment" : "deliverable"} ${base} in this worktree.`);
62
63
  const path = inner ? join(folder, inner) : folder;
63
64
  if (outside(relative(resolve(worktree, folder), resolve(worktree, path)))) throw new Error(`${path} is outside ${folder}, so it was not ${verb}.`);
64
- const root = await realpath(worktree);
65
- const target = await realpath(resolve(root, path)).catch(() => null);
66
- if (!target) throw new Error(`${path} no longer exists.`);
67
- const inside = relative(root, target);
65
+ const realRoot = await realpath(worktree);
66
+ const realTarget = await realpath(resolve(realRoot, path)).catch(() => null);
67
+ if (!realTarget) throw new Error(`${path} no longer exists.`);
68
+ const inside = relative(realRoot, realTarget);
68
69
  if (outside(inside)) throw new Error(`${path} is outside this worktree, so it was not ${verb}.`);
69
- return { path, target, root };
70
+ return { path, realTarget, realRoot, worktree };
70
71
  }
71
72
 
72
73
  export async function openDeliverable(directory: string, id: string, opener: Opener): Promise<{ opened: string }> {
73
- const { target } = await resolveAttachment(directory, id, "opened");
74
- await opener(target);
75
- return { opened: target };
74
+ const { realTarget } = await resolveAttachment(directory, id, "opened");
75
+ await opener(realTarget);
76
+ return { opened: realTarget };
76
77
  }
77
78
 
78
79
  // Large enough for full-page screenshots; the image travels over the socket.
@@ -93,34 +94,39 @@ export type Preview =
93
94
  | { kind: "image"; dataUri: string; bytes: number }
94
95
  | { kind: "video"; mimeType: string; bytes: number }
95
96
  | { kind: "text"; text: string; bytes: number; truncated: boolean }
97
+ | { kind: "markdown"; markdown: string; bytes: number; truncated: boolean }
96
98
  | { kind: "html"; html: string; bytes: number }
97
99
  | { kind: "folder"; entries: FolderEntry[]; truncated: boolean };
98
100
 
99
101
  export async function previewDeliverable(directory: string, id: string): Promise<Preview> {
100
- const { path, target, root } = await resolveAttachment(directory, id, "previewed");
101
- const stats = await stat(target);
102
- if (stats.isDirectory()) return { kind: "folder", ...(await listFolder(target)) };
103
- const kind = previewKind(target);
102
+ const { path, realTarget, realRoot, worktree } = await resolveAttachment(directory, id, "previewed");
103
+ const stats = await stat(realTarget);
104
+ if (stats.isDirectory()) return { kind: "folder", ...(await listFolder(realTarget)) };
105
+ const kind = previewKind(realTarget);
104
106
  if (!kind) throw new Error(`${path} isn't an image, video, text or HTML file, so it can't be previewed.`);
105
107
  const { size } = stats;
106
108
  if (kind === "video") {
107
109
  if (size > MAX_PREVIEW_VIDEO_BYTES) throw new Error(tooLarge(path, size));
108
- return { kind, mimeType: videoMimeType(target)!, bytes: size };
110
+ return { kind, mimeType: videoMimeType(realTarget)!, bytes: size };
109
111
  }
110
112
  if (kind === "text") {
111
113
  // Read only what's shown, so a huge log doesn't load whole.
112
- const file = await open(target);
114
+ const file = await open(realTarget);
113
115
  try {
114
116
  const { buffer, bytesRead } = await file.read({ buffer: Buffer.alloc(Math.min(size, MAX_PREVIEW_TEXT_BYTES)), position: 0 });
115
- return { kind, text: buffer.subarray(0, bytesRead).toString("utf8"), bytes: size, truncated: size > MAX_PREVIEW_TEXT_BYTES };
117
+ const bytes = buffer.subarray(0, bytesRead);
118
+ const truncated = size > MAX_PREVIEW_TEXT_BYTES;
119
+ if (textFormat(realTarget) === "plain") return { kind, text: previewText(bytes, truncated), bytes: size, truncated };
120
+ const where = { root: worktree, folder: dirname(resolve(worktree, path)), path: nodePath };
121
+ return { kind: "markdown", markdown: prepareMarkdown(bytes, truncated, where), bytes: size, truncated };
116
122
  } finally {
117
123
  await file.close();
118
124
  }
119
125
  }
120
126
  if (size > MAX_PREVIEW_BYTES) throw new Error(tooLarge(path, size));
121
- if (kind === "html") return { kind, html: await inlineLocalFiles(await readFile(target, "utf8"), dirname(target), root), bytes: size };
122
- const data = await readFile(target);
123
- return { kind, dataUri: `data:${imageMimeType(target)};base64,${data.toString("base64")}`, bytes: size };
127
+ if (kind === "html") return { kind, html: await inlineLocalFiles(await readFile(realTarget, "utf8"), dirname(realTarget), realRoot), bytes: size };
128
+ const data = await readFile(realTarget);
129
+ return { kind, dataUri: `data:${imageMimeType(realTarget)};base64,${data.toString("base64")}`, bytes: size };
124
130
  }
125
131
 
126
132
  async function listFolder(target: string): Promise<{ entries: FolderEntry[]; truncated: boolean }> {
@@ -192,9 +198,9 @@ function tooLarge(path: string, size: number): string {
192
198
  // One piece of a video, from `offset`. Resolved by reference each time, like the
193
199
  // preview, so a piece can only come from a video the agent recorded.
194
200
  export async function readDeliverablePiece(directory: string, id: string, offset: number): Promise<{ data: string; bytes: number }> {
195
- const { path, target } = await resolveAttachment(directory, id, "previewed");
196
- if (previewKind(target) !== "video") throw new Error(`${path} isn't a video.`);
197
- const file = await open(target);
201
+ const { path, realTarget } = await resolveAttachment(directory, id, "previewed");
202
+ if (previewKind(realTarget) !== "video") throw new Error(`${path} isn't a video.`);
203
+ const file = await open(realTarget);
198
204
  try {
199
205
  const { size } = await file.stat();
200
206
  if (size > MAX_PREVIEW_VIDEO_BYTES) throw new Error(tooLarge(path, size));
@@ -7,7 +7,7 @@ import { homedir } from "node:os";
7
7
  import { join } from "node:path";
8
8
  import { z } from "zod";
9
9
  import { NOTE_KINDS, type NoteKind, type WhatsNew, type WhatsNewNote, type WhatsNewRelease } from "../shared/whats-new.ts";
10
- import { spans } from "../shared/write-up.ts";
10
+ import { spans } from "../shared/blocks.ts";
11
11
  import { findPluginDirectory } from "./skill.ts";
12
12
  import { stateRoot, writeAtomic } from "./store.ts";
13
13
 
@@ -64,9 +64,20 @@ const VIDEO_TYPES: Record<string, string> = {
64
64
  ".webm": "video/webm",
65
65
  };
66
66
 
67
- // Plain text shown as-is in the preview dialog, and opened in the browser on
68
- // the host.
69
- const TEXT_EXTENSIONS = new Set([".md", ".markdown", ".mdx", ".txt", ".log", ".json", ".jsonl", ".yaml", ".yml", ".csv"]);
67
+ // MDX stays plain: Paseo's Markdown would drop its components without a trace
68
+ // and run its imports into prose.
69
+ const TEXT_FORMATS: Record<string, "markdown" | "plain"> = {
70
+ ".md": "markdown",
71
+ ".markdown": "markdown",
72
+ ".mdx": "plain",
73
+ ".txt": "plain",
74
+ ".log": "plain",
75
+ ".json": "plain",
76
+ ".jsonl": "plain",
77
+ ".yaml": "plain",
78
+ ".yml": "plain",
79
+ ".csv": "plain",
80
+ };
70
81
 
71
82
  function extension(path: string): string | null {
72
83
  return path.toLowerCase().match(/\.[a-z0-9]+$/)?.[0] ?? null;
@@ -82,12 +93,16 @@ export function videoMimeType(path: string): string | null {
82
93
  return VIDEO_TYPES[extension(path) ?? ""] ?? null;
83
94
  }
84
95
 
96
+ export function textFormat(path: string): "markdown" | "plain" | null {
97
+ return TEXT_FORMATS[extension(path) ?? ""] ?? null;
98
+ }
99
+
85
100
  export function previewKind(path: string): "image" | "video" | "text" | "html" | null {
86
101
  if (imageMimeType(path)) return "image";
87
102
  if (videoMimeType(path)) return "video";
88
103
  const ext = extension(path) ?? "";
89
104
  if (ext === ".html" || ext === ".htm") return "html";
90
- return TEXT_EXTENSIONS.has(ext) ? "text" : null;
105
+ return textFormat(path) ? "text" : null;
91
106
  }
92
107
 
93
108
  export function isFolder(attachment: Attachment): boolean {
@@ -1,8 +1,3 @@
1
- // A ticket's write-up (its Markdown source file) as simple blocks the dialog
2
- // can draw without a Markdown renderer: headings, paragraphs, list items and
3
- // code. Inline marks are kept as spans: `code` stays code, **bold** stays
4
- // bold, _emphasis_ loses its markers, and [links](url) keep only their text.
5
-
6
1
  export type Span = { text: string; code?: boolean; strong?: boolean };
7
2
 
8
3
  export type Block =
@@ -30,7 +25,7 @@ export function spans(text: string): Span[] {
30
25
  return result;
31
26
  }
32
27
 
33
- export function parseWriteUp(markdown: string): Block[] {
28
+ export function parseBlocks(markdown: string): Block[] {
34
29
  const blocks: Block[] = [];
35
30
  let paragraph: string[] = [];
36
31
  // A fence opened inside a list item is indented like the item; its lines
@@ -75,21 +70,6 @@ export function parseWriteUp(markdown: string): Block[] {
75
70
  return blocks;
76
71
  }
77
72
 
78
- // The write-up without a leading top heading, which only repeats the ticket's title.
79
73
  export function withoutTitle(blocks: Block[]): Block[] {
80
74
  return blocks[0]?.kind === "heading" && blocks[0].level === 1 ? blocks.slice(1) : blocks;
81
75
  }
82
-
83
- // The first blocks of the write-up, up to about `maxChars` of text, for the excerpt.
84
- export function writeUpExcerpt(blocks: Block[], maxChars = 420): { blocks: Block[]; more: boolean } {
85
- const body = withoutTitle(blocks);
86
- const shown: Block[] = [];
87
- let chars = 0;
88
- for (const block of body) {
89
- const length = block.kind === "code" ? block.text.length : block.spans.reduce((sum, span) => sum + span.text.length, 0);
90
- if (shown.length && chars + length > maxChars) break;
91
- shown.push(block);
92
- chars += length;
93
- }
94
- return { blocks: shown, more: shown.length < body.length };
95
- }