@stevecastaneda/paseo-progress 1.10.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/OVERVIEW.md +2 -2
- package/README.md +14 -3
- package/client/attachments.tsx +1 -1
- package/client/blocks.tsx +41 -0
- package/client/deliverables.tsx +1 -1
- package/client/dialog-stack.tsx +2 -2
- package/client/history.tsx +1 -1
- package/client/launcher.tsx +1 -1
- package/client/panel.tsx +1 -1
- package/client/pill-popover.tsx +1 -1
- package/client/pills.tsx +4 -13
- package/client/preview.tsx +50 -17
- package/client/questions.tsx +1 -1
- package/client/section-title.tsx +1 -1
- package/client/settings.tsx +4 -4
- package/client/show-more.tsx +1 -1
- package/client/spinner.tsx +1 -1
- package/client/tabs.tsx +1 -1
- package/client/ticket-dialog.tsx +1 -1
- package/client/whats-new.tsx +3 -3
- package/client/write-up.tsx +25 -44
- package/dist/server/cli.js +29 -16
- package/index.client.tsx +1 -2
- package/package.json +3 -3
- package/paseo-plugin.json +1 -1
- package/server/cli.ts +28 -13
- package/server/open.ts +30 -24
- package/server/whats-new.ts +1 -1
- package/shared/attachments.ts +19 -4
- package/shared/{write-up.ts → blocks.ts} +1 -21
- package/shared/dashboard.ts +1 -0
- package/shared/events.ts +1 -1
- package/shared/markdown.ts +483 -0
- package/shared/rpc.ts +4 -2
- package/skills/paseo-progress/SKILL.md +1 -1
package/dist/server/cli.js
CHANGED
|
@@ -19796,14 +19796,14 @@ var eventSchema = external_exports.discriminatedUnion("type", [
|
|
|
19796
19796
|
// True when the agent had to undo work built on another option.
|
|
19797
19797
|
changedCourse: external_exports.boolean().optional()
|
|
19798
19798
|
}),
|
|
19799
|
-
// Adds detail to an open question; its reference and default stay.
|
|
19800
19799
|
external_exports.object({
|
|
19801
19800
|
...base,
|
|
19802
19801
|
type: external_exports.literal("question.update"),
|
|
19803
19802
|
id: text,
|
|
19804
19803
|
question: text.optional(),
|
|
19805
19804
|
background: text.optional(),
|
|
19806
|
-
files: external_exports.array(fileLinkSchema).optional()
|
|
19805
|
+
files: external_exports.array(fileLinkSchema).optional(),
|
|
19806
|
+
recommend: text.optional()
|
|
19807
19807
|
}),
|
|
19808
19808
|
external_exports.object({ ...base, type: external_exports.literal("question.remove"), id: text }),
|
|
19809
19809
|
// `path` is relative to the worktree root, or absolute when outside it.
|
|
@@ -19971,6 +19971,7 @@ function reduceProgress(parsed, now) {
|
|
|
19971
19971
|
if (event.question !== void 0) question.question = event.question;
|
|
19972
19972
|
if (event.background !== void 0) question.background = event.background;
|
|
19973
19973
|
if (event.files !== void 0) question.files = [...question.files, ...event.files];
|
|
19974
|
+
if (event.recommend !== void 0) question.recommended = event.recommend;
|
|
19974
19975
|
break;
|
|
19975
19976
|
}
|
|
19976
19977
|
case "question.remove":
|
|
@@ -20702,6 +20703,13 @@ function existingQuestion(state, id) {
|
|
|
20702
20703
|
}
|
|
20703
20704
|
return question;
|
|
20704
20705
|
}
|
|
20706
|
+
function recommendOption(recommend, letters) {
|
|
20707
|
+
if (!letters.length) return recommend;
|
|
20708
|
+
if (!/^[A-Za-z]$/.test(recommend) || !letters.includes(recommend.toUpperCase())) {
|
|
20709
|
+
throw new UsageError(`--recommend must be one of the option letters: ${letters.join(", ")}.`);
|
|
20710
|
+
}
|
|
20711
|
+
return recommend.toUpperCase();
|
|
20712
|
+
}
|
|
20705
20713
|
function existingTicket(state, id) {
|
|
20706
20714
|
const wanted = required2(id, "ticket id").toUpperCase();
|
|
20707
20715
|
const ticket = state.dashboard.tickets.find((candidate) => candidate.id.toUpperCase() === wanted);
|
|
@@ -20840,13 +20848,7 @@ ${modeLine(mode)}`
|
|
|
20840
20848
|
const letters = options.map((option) => option.letter);
|
|
20841
20849
|
const duplicate = letters.find((letter, index) => letters.indexOf(letter) !== index);
|
|
20842
20850
|
if (duplicate) throw new UsageError(`Option ${duplicate} is given twice.`);
|
|
20843
|
-
|
|
20844
|
-
if (options.length) {
|
|
20845
|
-
if (!/^[A-Za-z]$/.test(recommend) || !letters.includes(recommend.toUpperCase())) {
|
|
20846
|
-
throw new UsageError(`--recommend must be one of the option letters: ${letters.join(", ")}.`);
|
|
20847
|
-
}
|
|
20848
|
-
recommend = recommend.toUpperCase();
|
|
20849
|
-
}
|
|
20851
|
+
const recommend = recommendOption(required2(stringOption(values.recommend), "--recommend (the option you would pick)"), letters);
|
|
20850
20852
|
const id = state.nextIds.question;
|
|
20851
20853
|
const big = values.big === void 0 ? void 0 : required2(stringOption(values.big), "--big reason (why the question is big)");
|
|
20852
20854
|
const rules = QUESTION_MODE_RULES[mode];
|
|
@@ -20892,21 +20894,32 @@ ${modeLine(mode)}`
|
|
|
20892
20894
|
}
|
|
20893
20895
|
},
|
|
20894
20896
|
"question update": {
|
|
20895
|
-
usage: `question update <id> [--background <text>] [--question <text>] [--file <path or http(s) URL>[=<label>] ...]`,
|
|
20896
|
-
summary: "Add detail to a question: replace its background or wording, or add files. Its reference
|
|
20897
|
-
options: { background: { type: "string" }, question: { type: "string" }, file: { type: "string", multiple: true } },
|
|
20898
|
-
run({ positionals, values, state, cwd, root }) {
|
|
20897
|
+
usage: `question update <id> [--recommend <letter or word>] [--background <text>] [--question <text>] [--file <path or http(s) URL>[=<label>] ...]`,
|
|
20898
|
+
summary: "Add detail to a question: change your recommendation while it's open, replace its background or wording, or add files. Its reference stays.",
|
|
20899
|
+
options: { recommend: { type: "string" }, background: { type: "string" }, question: { type: "string" }, file: { type: "string", multiple: true } },
|
|
20900
|
+
run({ positionals, values, state, cwd, root, mode }) {
|
|
20899
20901
|
const question = existingQuestion(state, positionals[0]);
|
|
20900
20902
|
const files = listOption(values.file).map((raw) => parseFile(raw, cwd, root));
|
|
20903
|
+
let recommend;
|
|
20904
|
+
if (values.recommend !== void 0) {
|
|
20905
|
+
if (question.answer) throw new UsageError(`${question.id} was already answered, so its recommendation can't change.`);
|
|
20906
|
+
const letters = question.options.map((option) => option.letter);
|
|
20907
|
+
recommend = recommendOption(required2(stringOption(values.recommend), "--recommend (the option you would pick)"), letters);
|
|
20908
|
+
if (recommend === question.recommended) throw new UsageError(`${question.id} already recommends ${recommend}.`);
|
|
20909
|
+
}
|
|
20901
20910
|
const event = {
|
|
20902
20911
|
type: "question.update",
|
|
20903
20912
|
id: question.id,
|
|
20904
20913
|
question: stringOption(values.question),
|
|
20905
20914
|
background: stringOption(values.background),
|
|
20906
|
-
files: files.length ? files : void 0
|
|
20915
|
+
files: files.length ? files : void 0,
|
|
20916
|
+
recommend
|
|
20907
20917
|
};
|
|
20908
|
-
if (!event.question && !event.background && !event.files) throw new UsageError("Nothing to change. Pass --background, --question, or --file.");
|
|
20909
|
-
return { event, message: `Updated ${question.id} (${question.title})` };
|
|
20918
|
+
if (!event.question && !event.background && !event.files && !recommend) throw new UsageError("Nothing to change. Pass --recommend, --background, --question, or --file.");
|
|
20919
|
+
if (!recommend) return { event, message: `Updated ${question.id} (${question.title})` };
|
|
20920
|
+
const rules = QUESTION_MODE_RULES[mode];
|
|
20921
|
+
const next = question.state === "building" ? `Undo the work built on ${question.recommended} and build ${recommend} instead.` : `Mode: ${rules.label}. ${rules.askRule({ recommend, big: Boolean(question.big) })}`;
|
|
20922
|
+
return { event, message: `${question.id} now recommends ${recommend} (was ${question.recommended}). ${next}` };
|
|
20910
20923
|
}
|
|
20911
20924
|
},
|
|
20912
20925
|
"question remove": {
|
package/index.client.tsx
CHANGED
|
@@ -9,12 +9,11 @@ const SETTINGS_ID = "settings";
|
|
|
9
9
|
|
|
10
10
|
export default function contribute(client: PluginClientContext) {
|
|
11
11
|
const stopPills = contributePills(client);
|
|
12
|
-
const panel = progressPanel(
|
|
12
|
+
const panel = progressPanel();
|
|
13
13
|
const stopPanel = client.addWorkspacePanel({
|
|
14
14
|
id: panel.id,
|
|
15
15
|
title: "Progress",
|
|
16
16
|
icon: "ListChecks",
|
|
17
|
-
context: "workspace",
|
|
18
17
|
locations: [panel.location],
|
|
19
18
|
Component: (props) => <ProgressLanguageProvider><ProgressPanel {...props} openSettings={() => client.openSettings(SETTINGS_ID)} /></ProgressLanguageProvider>,
|
|
20
19
|
});
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@stevecastaneda/paseo-progress",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.12.0",
|
|
4
4
|
"description": "A live progress dashboard per worktree for Paseo, drawn from a file agents write with the paseo-progress command.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"author": "Steve Castaneda",
|
|
@@ -50,8 +50,8 @@
|
|
|
50
50
|
"postpack": "rm -rf dist"
|
|
51
51
|
},
|
|
52
52
|
"devDependencies": {
|
|
53
|
-
"@getpaseo/client": "
|
|
54
|
-
"@getpaseo/plugin": "
|
|
53
|
+
"@getpaseo/client": "0.13.0",
|
|
54
|
+
"@getpaseo/plugin": "0.13.0",
|
|
55
55
|
"@tanstack/react-query": "^5.90.11",
|
|
56
56
|
"@types/node": "^20.9.0",
|
|
57
57
|
"@types/react": "~19.2.0",
|
package/paseo-plugin.json
CHANGED
package/server/cli.ts
CHANGED
|
@@ -121,6 +121,14 @@ function existingQuestion(state: State, id: string | undefined) {
|
|
|
121
121
|
return question;
|
|
122
122
|
}
|
|
123
123
|
|
|
124
|
+
function recommendOption(recommend: string, letters: string[]): string {
|
|
125
|
+
if (!letters.length) return recommend;
|
|
126
|
+
if (!/^[A-Za-z]$/.test(recommend) || !letters.includes(recommend.toUpperCase())) {
|
|
127
|
+
throw new UsageError(`--recommend must be one of the option letters: ${letters.join(", ")}.`);
|
|
128
|
+
}
|
|
129
|
+
return recommend.toUpperCase();
|
|
130
|
+
}
|
|
131
|
+
|
|
124
132
|
function existingTicket(state: State, id: string | undefined) {
|
|
125
133
|
const wanted = required(id, "ticket id").toUpperCase();
|
|
126
134
|
const ticket = state.dashboard.tickets.find((candidate) => candidate.id.toUpperCase() === wanted);
|
|
@@ -269,13 +277,7 @@ const commands: Record<string, Command> = {
|
|
|
269
277
|
const letters = options.map((option) => option.letter);
|
|
270
278
|
const duplicate = letters.find((letter, index) => letters.indexOf(letter) !== index);
|
|
271
279
|
if (duplicate) throw new UsageError(`Option ${duplicate} is given twice.`);
|
|
272
|
-
|
|
273
|
-
if (options.length) {
|
|
274
|
-
if (!/^[A-Za-z]$/.test(recommend) || !letters.includes(recommend.toUpperCase())) {
|
|
275
|
-
throw new UsageError(`--recommend must be one of the option letters: ${letters.join(", ")}.`);
|
|
276
|
-
}
|
|
277
|
-
recommend = recommend.toUpperCase();
|
|
278
|
-
}
|
|
280
|
+
const recommend = recommendOption(required(stringOption(values.recommend), "--recommend (the option you would pick)"), letters);
|
|
279
281
|
const id = state.nextIds.question;
|
|
280
282
|
const big = values.big === undefined ? undefined : required(stringOption(values.big), "--big reason (why the question is big)");
|
|
281
283
|
const rules = QUESTION_MODE_RULES[mode];
|
|
@@ -323,21 +325,34 @@ const commands: Record<string, Command> = {
|
|
|
323
325
|
},
|
|
324
326
|
},
|
|
325
327
|
"question update": {
|
|
326
|
-
usage: `question update <id> [--background <text>] [--question <text>] [--file <path or http(s) URL>[=<label>] ...]`,
|
|
327
|
-
summary: "Add detail to a question: replace its background or wording, or add files. Its reference
|
|
328
|
-
options: { background: { type: "string" }, question: { type: "string" }, file: { type: "string", multiple: true } },
|
|
329
|
-
run({ positionals, values, state, cwd, root }) {
|
|
328
|
+
usage: `question update <id> [--recommend <letter or word>] [--background <text>] [--question <text>] [--file <path or http(s) URL>[=<label>] ...]`,
|
|
329
|
+
summary: "Add detail to a question: change your recommendation while it's open, replace its background or wording, or add files. Its reference stays.",
|
|
330
|
+
options: { recommend: { type: "string" }, background: { type: "string" }, question: { type: "string" }, file: { type: "string", multiple: true } },
|
|
331
|
+
run({ positionals, values, state, cwd, root, mode }) {
|
|
330
332
|
const question = existingQuestion(state, positionals[0]);
|
|
331
333
|
const files = listOption(values.file).map((raw) => parseFile(raw, cwd, root));
|
|
334
|
+
let recommend: string | undefined;
|
|
335
|
+
if (values.recommend !== undefined) {
|
|
336
|
+
if (question.answer) throw new UsageError(`${question.id} was already answered, so its recommendation can't change.`);
|
|
337
|
+
const letters = question.options.map((option) => option.letter);
|
|
338
|
+
recommend = recommendOption(required(stringOption(values.recommend), "--recommend (the option you would pick)"), letters);
|
|
339
|
+
if (recommend === question.recommended) throw new UsageError(`${question.id} already recommends ${recommend}.`);
|
|
340
|
+
}
|
|
332
341
|
const event = {
|
|
333
342
|
type: "question.update" as const,
|
|
334
343
|
id: question.id,
|
|
335
344
|
question: stringOption(values.question),
|
|
336
345
|
background: stringOption(values.background),
|
|
337
346
|
files: files.length ? files : undefined,
|
|
347
|
+
recommend,
|
|
338
348
|
};
|
|
339
|
-
if (!event.question && !event.background && !event.files) throw new UsageError("Nothing to change. Pass --background, --question, or --file.");
|
|
340
|
-
return { event, message: `Updated ${question.id} (${question.title})` };
|
|
349
|
+
if (!event.question && !event.background && !event.files && !recommend) throw new UsageError("Nothing to change. Pass --recommend, --background, --question, or --file.");
|
|
350
|
+
if (!recommend) return { event, message: `Updated ${question.id} (${question.title})` };
|
|
351
|
+
const rules = QUESTION_MODE_RULES[mode];
|
|
352
|
+
const next = question.state === "building"
|
|
353
|
+
? `Undo the work built on ${question.recommended} and build ${recommend} instead.`
|
|
354
|
+
: `Mode: ${rules.label}. ${rules.askRule({ recommend, big: Boolean(question.big) })}`;
|
|
355
|
+
return { event, message: `${question.id} now recommends ${recommend} (was ${question.recommended}). ${next}` };
|
|
341
356
|
},
|
|
342
357
|
},
|
|
343
358
|
"question remove": {
|
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;
|
|
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
|
|
65
|
-
const
|
|
66
|
-
if (!
|
|
67
|
-
const inside = relative(
|
|
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,
|
|
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 {
|
|
74
|
-
await opener(
|
|
75
|
-
return { opened:
|
|
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,
|
|
101
|
-
const stats = await stat(
|
|
102
|
-
if (stats.isDirectory()) return { kind: "folder", ...(await listFolder(
|
|
103
|
-
const kind = previewKind(
|
|
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(
|
|
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(
|
|
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
|
-
|
|
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(
|
|
122
|
-
const data = await readFile(
|
|
123
|
-
return { kind, dataUri: `data:${imageMimeType(
|
|
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,
|
|
196
|
-
if (previewKind(
|
|
197
|
-
const file = await open(
|
|
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));
|
package/server/whats-new.ts
CHANGED
|
@@ -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/
|
|
10
|
+
import { spans } from "../shared/blocks.ts";
|
|
11
11
|
import { findPluginDirectory } from "./skill.ts";
|
|
12
12
|
import { stateRoot, writeAtomic } from "./store.ts";
|
|
13
13
|
|
package/shared/attachments.ts
CHANGED
|
@@ -64,9 +64,20 @@ const VIDEO_TYPES: Record<string, string> = {
|
|
|
64
64
|
".webm": "video/webm",
|
|
65
65
|
};
|
|
66
66
|
|
|
67
|
-
//
|
|
68
|
-
//
|
|
69
|
-
const
|
|
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
|
|
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
|
|
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
|
-
}
|
package/shared/dashboard.ts
CHANGED
|
@@ -308,6 +308,7 @@ export function reduceProgress(parsed: { events: ParsedLine[]; issues: FileIssue
|
|
|
308
308
|
if (event.question !== undefined) question.question = event.question;
|
|
309
309
|
if (event.background !== undefined) question.background = event.background;
|
|
310
310
|
if (event.files !== undefined) question.files = [...question.files, ...event.files];
|
|
311
|
+
if (event.recommend !== undefined) question.recommended = event.recommend;
|
|
311
312
|
break;
|
|
312
313
|
}
|
|
313
314
|
case "question.remove":
|
package/shared/events.ts
CHANGED
|
@@ -112,7 +112,6 @@ export const eventSchema = z.discriminatedUnion("type", [
|
|
|
112
112
|
// True when the agent had to undo work built on another option.
|
|
113
113
|
changedCourse: z.boolean().optional(),
|
|
114
114
|
}),
|
|
115
|
-
// Adds detail to an open question; its reference and default stay.
|
|
116
115
|
z.object({
|
|
117
116
|
...base,
|
|
118
117
|
type: z.literal("question.update"),
|
|
@@ -120,6 +119,7 @@ export const eventSchema = z.discriminatedUnion("type", [
|
|
|
120
119
|
question: text.optional(),
|
|
121
120
|
background: text.optional(),
|
|
122
121
|
files: z.array(fileLinkSchema).optional(),
|
|
122
|
+
recommend: text.optional(),
|
|
123
123
|
}),
|
|
124
124
|
z.object({ ...base, type: z.literal("question.remove"), id: text }),
|
|
125
125
|
// `path` is relative to the worktree root, or absolute when outside it.
|