@stevecastaneda/paseo-progress 1.14.2 → 1.14.3
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 +24 -17
- package/client/attachments.tsx +122 -28
- package/client/blocks.tsx +33 -8
- package/client/chat-row-view.tsx +19 -6
- package/client/chat-row.ts +13 -2
- package/client/dashboard-query.ts +9 -4
- package/client/deliverables.tsx +82 -17
- package/client/dialog-stack.tsx +104 -42
- package/client/directory.ts +1 -3
- package/client/errors.ts +6 -1
- package/client/history.tsx +233 -79
- package/client/i18n.ts +10 -5
- package/client/launcher.tsx +57 -15
- package/client/locales/en.ts +28 -22
- package/client/locales/zh-CN.ts +18 -17
- package/client/messages.ts +1 -0
- package/client/motion.tsx +38 -14
- package/client/panel-opened.ts +3 -1
- package/client/panel.tsx +442 -160
- package/client/pdf.tsx +18 -4
- package/client/pieces.ts +6 -1
- package/client/pill-popover.tsx +108 -23
- package/client/pills.tsx +35 -12
- package/client/preview.tsx +265 -79
- package/client/questions.tsx +173 -52
- package/client/row.tsx +14 -3
- package/client/section-title.tsx +11 -2
- package/client/settings.tsx +76 -24
- package/client/show-more.tsx +23 -6
- package/client/spinner.tsx +11 -7
- package/client/status-badge.tsx +9 -7
- package/client/tabs.tsx +98 -20
- package/client/ticket-dialog.tsx +241 -73
- package/client/time-bar.tsx +5 -2
- package/client/video.tsx +15 -6
- package/client/whats-new.tsx +94 -36
- package/client/when.tsx +2 -1
- package/client/write-up.tsx +79 -12
- package/dist/server/cli.js +199 -101
- package/index.client.tsx +28 -13
- package/index.server.ts +19 -1
- package/package.json +15 -14
- package/server/cli.ts +72 -32
- package/server/dashboard.ts +9 -13
- package/server/launcher.ts +33 -17
- package/server/lock.ts +14 -7
- package/server/open.ts +32 -98
- package/server/progress-file.ts +6 -6
- package/server/question-mode.ts +12 -6
- package/server/skill.ts +22 -16
- package/server/store.ts +26 -41
- package/server/system.ts +113 -0
- package/server/tool.ts +89 -44
- package/server/whats-new.ts +25 -13
- package/shared/attachments.ts +40 -8
- package/shared/blocks.ts +9 -6
- package/shared/dashboard.ts +46 -29
- package/shared/events.ts +12 -10
- package/shared/markdown.ts +16 -8
- package/shared/question-modes.ts +8 -5
- package/shared/rpc.ts +9 -3
- package/shared/show.ts +20 -8
- package/shared/ticket-story.ts +1 -3
- package/skills/paseo-progress/SKILL.md +1 -0
- package/tsconfig.json +3 -10
- package/client/page-opener.ts +0 -9
- package/client/page-screen.tsx +0 -97
package/server/tool.ts
CHANGED
|
@@ -14,10 +14,12 @@ export const agentToolsSupported = "defineAgentTool" in sdk;
|
|
|
14
14
|
// mirrors its typing; Paseo validates the input before execute.
|
|
15
15
|
type Tool = Omit<AgentToolDefinition, "name">;
|
|
16
16
|
|
|
17
|
-
function agentTool<Input extends z.ZodType>(
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
17
|
+
function agentTool<Input extends z.ZodType>(
|
|
18
|
+
tool: Omit<Tool, "input" | "execute"> & {
|
|
19
|
+
input: Input;
|
|
20
|
+
execute(input: z.output<Input>, context: AgentToolContext): Promise<AgentToolResult>;
|
|
21
|
+
},
|
|
22
|
+
): Tool {
|
|
21
23
|
return { ...tool, execute: (input, context) => tool.execute(input as z.output<Input>, context) };
|
|
22
24
|
}
|
|
23
25
|
|
|
@@ -39,8 +41,10 @@ function commandArgs(command: string, positionals: string[], flags: Flags = {}):
|
|
|
39
41
|
// The command's --help summary, with its flags renamed to this tool's fields.
|
|
40
42
|
const FIELD_FOR_FLAG: Record<string, string> = { estimate: "estimateMinutes", option: "options", file: "files" };
|
|
41
43
|
function toolSummary(command: string): string {
|
|
42
|
-
return commandSummary(command).replace(
|
|
43
|
-
|
|
44
|
+
return commandSummary(command).replace(
|
|
45
|
+
/--([a-z]+(?:-[a-z]+)*)/g,
|
|
46
|
+
(_, flag: string) => FIELD_FOR_FLAG[flag] ?? flag.replace(/-([a-z])/g, (_m, letter: string) => letter.toUpperCase()),
|
|
47
|
+
);
|
|
44
48
|
}
|
|
45
49
|
|
|
46
50
|
function commandTool<Input extends z.ZodType>(title: string, command: string, input: Input, args: (input: z.output<Input>) => string[], extra = "") {
|
|
@@ -54,28 +58,37 @@ function commandTool<Input extends z.ZodType>(title: string, command: string, in
|
|
|
54
58
|
|
|
55
59
|
const ticketId = (what: string) => z.string().describe(`${what}, like T03`);
|
|
56
60
|
const estimate = z.number().int().describe("Whole minutes, like 90");
|
|
57
|
-
const
|
|
61
|
+
const ticketSource = z.string().describe("Spec file path or http(s) link where the ticket is written up");
|
|
58
62
|
|
|
59
63
|
// Keyed by name, so the compiler holds this to the shared list.
|
|
60
64
|
const tools: Record<ProgressToolName, Tool> = {
|
|
61
65
|
progress_show: commandTool("Show", "show", z.object({}), () => ["show"]),
|
|
62
|
-
progress_start: commandTool(
|
|
66
|
+
progress_start: commandTool(
|
|
67
|
+
"Start run",
|
|
68
|
+
"start",
|
|
63
69
|
z.object({
|
|
64
70
|
title: z.string(),
|
|
65
71
|
subtitle: z.string().optional(),
|
|
66
72
|
itemLabel: z.string().optional().describe('What to call each item instead of "Ticket", like Step'),
|
|
67
73
|
}),
|
|
68
|
-
({ title, subtitle, itemLabel }) => commandArgs("start", [title], { subtitle, "item-label": itemLabel })
|
|
69
|
-
|
|
74
|
+
({ title, subtitle, itemLabel }) => commandArgs("start", [title], { subtitle, "item-label": itemLabel }),
|
|
75
|
+
),
|
|
76
|
+
progress_ticket_add: commandTool(
|
|
77
|
+
"Add ticket",
|
|
78
|
+
"ticket add",
|
|
70
79
|
z.object({
|
|
71
80
|
title: z.string().describe('Like "Ticket 01: Export button"'),
|
|
72
81
|
estimateMinutes: estimate,
|
|
73
82
|
status: ticketStatusSchema.optional(),
|
|
74
83
|
waitsFor: ticketId("A ticket this one can't start before").optional(),
|
|
75
|
-
source:
|
|
84
|
+
source: ticketSource.optional(),
|
|
76
85
|
}),
|
|
77
|
-
({ title, estimateMinutes, status, waitsFor, source }) =>
|
|
78
|
-
|
|
86
|
+
({ title, estimateMinutes, status, waitsFor, source }) =>
|
|
87
|
+
commandArgs("ticket add", [title], { estimate: estimateMinutes, status, "waits-for": waitsFor, source }),
|
|
88
|
+
),
|
|
89
|
+
progress_ticket_update: commandTool(
|
|
90
|
+
"Update ticket",
|
|
91
|
+
"ticket update",
|
|
79
92
|
z.object({
|
|
80
93
|
id: ticketId("The ticket"),
|
|
81
94
|
status: ticketStatusSchema.optional(),
|
|
@@ -87,58 +100,81 @@ const tools: Record<ProgressToolName, Tool> = {
|
|
|
87
100
|
source: z.string().optional().describe('Spec file path or http(s) link where the ticket is written up; "" clears it'),
|
|
88
101
|
}),
|
|
89
102
|
({ id, status, stage, title, estimateMinutes, note, waitsFor, source }) =>
|
|
90
|
-
commandArgs("ticket update", [id], { status, stage, title, estimate: estimateMinutes, note, "waits-for": waitsFor, source })
|
|
91
|
-
|
|
103
|
+
commandArgs("ticket update", [id], { status, stage, title, estimate: estimateMinutes, note, "waits-for": waitsFor, source }),
|
|
104
|
+
),
|
|
105
|
+
progress_question_ask: commandTool(
|
|
106
|
+
"Ask question",
|
|
107
|
+
"question ask",
|
|
92
108
|
z.object({
|
|
93
109
|
title: z.string().describe("A short title"),
|
|
94
110
|
question: z.string(),
|
|
95
|
-
options: z.array(
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
111
|
+
options: z.array(
|
|
112
|
+
z.object({
|
|
113
|
+
letter: z.string().describe("A, B, C, ..."),
|
|
114
|
+
label: z.string(),
|
|
115
|
+
consequence: z.string().describe("What picking it means for the user"),
|
|
116
|
+
}),
|
|
117
|
+
),
|
|
100
118
|
recommend: z.string().describe("The option letter you would pick"),
|
|
101
119
|
big: z.string().optional().describe("Only for a big question: which test it meets, in a few plain words"),
|
|
102
120
|
background: z.string().optional(),
|
|
103
|
-
files: z
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
121
|
+
files: z
|
|
122
|
+
.array(
|
|
123
|
+
z.object({
|
|
124
|
+
target: z.string().describe("A file path or http(s) link, like a screenshot"),
|
|
125
|
+
label: z.string().optional(),
|
|
126
|
+
}),
|
|
127
|
+
)
|
|
128
|
+
.optional(),
|
|
107
129
|
ticket: ticketId("The ticket it's about").optional(),
|
|
108
130
|
}),
|
|
109
|
-
({ title, question, options, recommend, big, background, files, ticket }) =>
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
131
|
+
({ title, question, options, recommend, big, background, files, ticket }) =>
|
|
132
|
+
commandArgs("question ask", [title, question], {
|
|
133
|
+
option: options.map(({ letter, label, consequence }) => `${letter}=${label} | ${consequence}`),
|
|
134
|
+
recommend,
|
|
135
|
+
big,
|
|
136
|
+
background,
|
|
137
|
+
file: files?.map(({ target, label }) => (label ? `${target}=${label}` : target)),
|
|
138
|
+
ticket,
|
|
139
|
+
}),
|
|
140
|
+
),
|
|
141
|
+
progress_question_answer: commandTool(
|
|
142
|
+
"Record answer",
|
|
143
|
+
"question answer",
|
|
118
144
|
z.object({
|
|
119
145
|
id: z.string().describe("The question, like Q2"),
|
|
120
146
|
choice: z.string().describe("An option letter, or the user's own short answer"),
|
|
121
147
|
words: z.string().optional().describe("What the user said"),
|
|
122
148
|
}),
|
|
123
|
-
({ id, choice, words }) => commandArgs("question answer", [id, choice], { words })
|
|
124
|
-
|
|
149
|
+
({ id, choice, words }) => commandArgs("question answer", [id, choice], { words }),
|
|
150
|
+
),
|
|
151
|
+
progress_deliverable_add: commandTool(
|
|
152
|
+
"Add deliverable",
|
|
153
|
+
"deliverable add",
|
|
125
154
|
z.object({
|
|
126
155
|
title: z.string(),
|
|
127
156
|
target: z.string().describe("A file or folder path, or an http(s) link"),
|
|
128
157
|
ticket: ticketId("The ticket it belongs to").optional(),
|
|
129
158
|
kind: z.enum(DELIVERABLE_KINDS).optional(),
|
|
130
159
|
}),
|
|
131
|
-
({ title, target, ticket, kind }) => commandArgs("deliverable add", [title, target], { ticket, kind })
|
|
132
|
-
|
|
160
|
+
({ title, target, ticket, kind }) => commandArgs("deliverable add", [title, target], { ticket, kind }),
|
|
161
|
+
),
|
|
162
|
+
progress_activity_add: commandTool(
|
|
163
|
+
"Log activity",
|
|
164
|
+
"activity add",
|
|
133
165
|
z.object({ text: z.string(), ticket: ticketId("The ticket it's about").optional() }),
|
|
134
|
-
({ text, ticket }) => commandArgs("activity add", [text], { ticket })
|
|
135
|
-
|
|
166
|
+
({ text, ticket }) => commandArgs("activity add", [text], { ticket }),
|
|
167
|
+
),
|
|
168
|
+
progress_ticker: commandTool(
|
|
169
|
+
"Set ticker",
|
|
170
|
+
"ticker set",
|
|
136
171
|
z.object({ text: z.string() }),
|
|
137
172
|
({ text }) => (text.trim() ? commandArgs("ticker set", [text]) : commandArgs("ticker clear", [])),
|
|
138
|
-
' Pass "" to hide it.'
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
173
|
+
' Pass "" to hide it.',
|
|
174
|
+
),
|
|
175
|
+
progress_finish: commandTool("Finish run", "finish", z.object({ outcome: z.string().describe("One line") }), ({ outcome }) =>
|
|
176
|
+
commandArgs("finish", [outcome]),
|
|
177
|
+
),
|
|
142
178
|
paseo_progress: agentTool({
|
|
143
179
|
title: "Progress",
|
|
144
180
|
description: [
|
|
@@ -147,7 +183,16 @@ const tools: Record<ProgressToolName, Tool> = {
|
|
|
147
183
|
'Pass ["--help"] to list the commands, or add "--help" after any command for its options.',
|
|
148
184
|
].join(" "),
|
|
149
185
|
input: z.object({ args: z.array(z.string()).min(1).describe("Arguments for paseo-progress, one per item") }),
|
|
150
|
-
|
|
186
|
+
// install-launcher writes a program wherever it's told, such as a git hook,
|
|
187
|
+
// so only the user installs it. Every other command writes only in
|
|
188
|
+
// Progress's own folder and the run-history folder the user chose.
|
|
189
|
+
execute: async ({ args }, context) =>
|
|
190
|
+
args[0] === "install-launcher"
|
|
191
|
+
? {
|
|
192
|
+
text: "Agents can't install the paseo-progress command. The user installs it with Install command in the Progress panel.",
|
|
193
|
+
isError: true,
|
|
194
|
+
}
|
|
195
|
+
: run(args, context),
|
|
151
196
|
}),
|
|
152
197
|
};
|
|
153
198
|
|
package/server/whats-new.ts
CHANGED
|
@@ -9,15 +9,18 @@ import { z } from "zod";
|
|
|
9
9
|
import { NOTE_KINDS, type NoteKind, type WhatsNew, type WhatsNewNote, type WhatsNewRelease } from "../shared/whats-new.ts";
|
|
10
10
|
import { spans } from "../shared/blocks.ts";
|
|
11
11
|
import { findPluginDirectory } from "./skill.ts";
|
|
12
|
-
import { stateRoot
|
|
12
|
+
import { stateRoot } from "./store.ts";
|
|
13
|
+
import { writeFileIn } from "./system.ts";
|
|
13
14
|
|
|
14
15
|
export type Release = { version: string; readme: string | null };
|
|
15
16
|
|
|
16
17
|
const seenSchema = z.object({ seen: z.string().regex(/^\d+\.\d+\.\d+$/) });
|
|
17
18
|
const packageSchema = z.object({ version: z.string() });
|
|
18
19
|
|
|
20
|
+
const WHATS_NEW_NAME = "whats-new.json";
|
|
21
|
+
|
|
19
22
|
export function whatsNewPath(home = homedir()): string {
|
|
20
|
-
return join(stateRoot(home),
|
|
23
|
+
return join(stateRoot(home), WHATS_NEW_NAME);
|
|
21
24
|
}
|
|
22
25
|
|
|
23
26
|
// Each `### x.y.z` under the README's `## Changelog` with its top-level
|
|
@@ -44,8 +47,8 @@ export function changelog(readme: string): WhatsNewRelease[] {
|
|
|
44
47
|
}
|
|
45
48
|
return releases
|
|
46
49
|
.filter((release) => release.bullets.length)
|
|
47
|
-
.map(({ version, bullets }) => ({ version, notes: bullets.map(
|
|
48
|
-
.
|
|
50
|
+
.map(({ version, bullets }) => ({ version, notes: bullets.map(noteFromBullet) }))
|
|
51
|
+
.toSorted((a, b) => compareVersions(b.version, a.version));
|
|
49
52
|
}
|
|
50
53
|
|
|
51
54
|
function compareVersions(a: string, b: string): number {
|
|
@@ -55,27 +58,36 @@ function compareVersions(a: string, b: string): number {
|
|
|
55
58
|
|
|
56
59
|
// A bullet can open with a kind tag, like `[Fixed]`, and then a bold
|
|
57
60
|
// sentence, like `**Title.** Detail.`, which is its title.
|
|
58
|
-
function
|
|
61
|
+
function noteFromBullet(bullet: string): WhatsNewNote {
|
|
59
62
|
const tag = /^\[([a-z]+)\]\s*(.*)$/i.exec(bullet);
|
|
60
63
|
const kind = tag ? noteKind(tag[1]) : undefined;
|
|
61
64
|
const rest = kind ? tag![2] : bullet;
|
|
62
|
-
const text = rest
|
|
65
|
+
const text = rest
|
|
66
|
+
.split(/(`[^`]+`)/)
|
|
67
|
+
.map((part, index) => (index % 2 ? part : curly(part)))
|
|
68
|
+
.join("");
|
|
63
69
|
const lead = /^\*\*([^*]+?[.!?])\*\*\s*(.*)$/.exec(text);
|
|
64
70
|
return { ...(kind && { kind }), ...(lead ? { title: plain(lead[1]), body: lead[2] } : { body: text }) };
|
|
65
71
|
}
|
|
66
72
|
|
|
67
73
|
function noteKind(tag: string): NoteKind | undefined {
|
|
68
74
|
const key = tag.toLowerCase();
|
|
69
|
-
return Object.hasOwn(NOTE_KINDS, key) ? key as NoteKind : undefined;
|
|
75
|
+
return Object.hasOwn(NOTE_KINDS, key) ? (key as NoteKind) : undefined;
|
|
70
76
|
}
|
|
71
77
|
|
|
72
78
|
// The README is typed with straight quotes; the panel shows typeset ones. Code keeps its own.
|
|
73
79
|
function curly(text: string): string {
|
|
74
|
-
return text
|
|
80
|
+
return text
|
|
81
|
+
.replace(/(^|[\s([{])"/g, "$1\u201c")
|
|
82
|
+
.replace(/"/g, "\u201d")
|
|
83
|
+
.replace(/(^|[\s([{])'/g, "$1\u2018")
|
|
84
|
+
.replace(/'/g, "\u2019");
|
|
75
85
|
}
|
|
76
86
|
|
|
77
87
|
function plain(markdown: string): string {
|
|
78
|
-
return spans(markdown)
|
|
88
|
+
return spans(markdown)
|
|
89
|
+
.map((span) => span.text)
|
|
90
|
+
.join("");
|
|
79
91
|
}
|
|
80
92
|
|
|
81
93
|
// A note's title, or else the first sentence of its body, without Markdown.
|
|
@@ -94,7 +106,7 @@ async function readSeen(home: string): Promise<string | null> {
|
|
|
94
106
|
}
|
|
95
107
|
|
|
96
108
|
export async function markWhatsNewSeen(version: string, home = homedir()): Promise<void> {
|
|
97
|
-
await
|
|
109
|
+
await writeFileIn(stateRoot(home), WHATS_NEW_NAME, `${JSON.stringify({ seen: version })}\n`, { atomic: true });
|
|
98
110
|
}
|
|
99
111
|
|
|
100
112
|
// A worktree's folder (see outsideDirectory) means the plugin was in use
|
|
@@ -115,9 +127,9 @@ export async function whatsNew({ version, readme }: Release, home = homedir()):
|
|
|
115
127
|
await markWhatsNewSeen(version, home);
|
|
116
128
|
return null;
|
|
117
129
|
}
|
|
118
|
-
const releases = (readme === null ? [] : changelog(readme)).filter((release) =>
|
|
119
|
-
? release.version === version
|
|
120
|
-
|
|
130
|
+
const releases = (readme === null ? [] : changelog(readme)).filter((release) =>
|
|
131
|
+
seen === null ? release.version === version : compareVersions(release.version, seen) > 0 && compareVersions(release.version, version) <= 0,
|
|
132
|
+
);
|
|
121
133
|
if (!releases.length) return null;
|
|
122
134
|
const notes = releases.flatMap((release) => release.notes);
|
|
123
135
|
return { version, headline: headline(notes.find((note) => note.kind === "new") ?? notes[0]), releases };
|
package/shared/attachments.ts
CHANGED
|
@@ -19,7 +19,18 @@ export function deliverableAttachment(deliverable: Deliverable): Attachment {
|
|
|
19
19
|
}
|
|
20
20
|
|
|
21
21
|
function fileAttachment(ref: string, file: FileLink): Attachment {
|
|
22
|
-
return {
|
|
22
|
+
return {
|
|
23
|
+
ref,
|
|
24
|
+
title:
|
|
25
|
+
file.label ??
|
|
26
|
+
(file.path ?? file.url ?? "")
|
|
27
|
+
.replace(/[\\/]+$/, "")
|
|
28
|
+
.split(/[\\/]/)
|
|
29
|
+
.pop() ??
|
|
30
|
+
"",
|
|
31
|
+
path: file.path,
|
|
32
|
+
url: file.url,
|
|
33
|
+
};
|
|
23
34
|
}
|
|
24
35
|
|
|
25
36
|
export function questionAttachments(question: Question): Attachment[] {
|
|
@@ -118,6 +129,18 @@ export function previewKind(path: string): PreviewKind | null {
|
|
|
118
129
|
return DOCUMENT_KINDS[extension(path) ?? ""] ?? (textFormat(path) ? "text" : null);
|
|
119
130
|
}
|
|
120
131
|
|
|
132
|
+
const AUDIO_EXTENSIONS = new Set([".mp3", ".m4a", ".aac", ".wav", ".flac", ".ogg", ".opus"]);
|
|
133
|
+
|
|
134
|
+
// Whether a file opens in the host's default app, the way a double-click
|
|
135
|
+
// would. Only kinds whose default app shows them and never runs them: a
|
|
136
|
+
// script, an app or an installer would run. SVG and HTML stay out too, since a
|
|
137
|
+
// browser opens them and their scripts run with the user's files at hand.
|
|
138
|
+
export function opensOnHost(path: string): boolean {
|
|
139
|
+
const kind = previewKind(path);
|
|
140
|
+
if (kind === "html" || extension(path) === ".svg") return false;
|
|
141
|
+
return kind !== null || AUDIO_EXTENSIONS.has(extension(path) ?? "");
|
|
142
|
+
}
|
|
143
|
+
|
|
121
144
|
export function isFolder(attachment: Attachment): boolean {
|
|
122
145
|
return attachment.kind === "folder" || /[\\/]$/.test(attachment.path ?? "");
|
|
123
146
|
}
|
|
@@ -176,13 +199,13 @@ export function attachmentIcon(attachment: Attachment): string {
|
|
|
176
199
|
if (attachment.url) return "Globe";
|
|
177
200
|
if (!attachment.path) return "File";
|
|
178
201
|
const kind = previewKind(attachment.path);
|
|
179
|
-
return kind ? PREVIEW_ICON[kind] :
|
|
202
|
+
return kind ? PREVIEW_ICON[kind] : attachment.path.endsWith("/") ? "Folder" : "File";
|
|
180
203
|
}
|
|
181
204
|
|
|
182
|
-
// An HTML page
|
|
183
|
-
//
|
|
184
|
-
export function
|
|
185
|
-
return
|
|
205
|
+
// An HTML page opens in a Paseo tab, in Paseo's own preview, which runs the
|
|
206
|
+
// page's scripts and blocks everything it would load from the web.
|
|
207
|
+
export function isPage(attachment: Attachment): boolean {
|
|
208
|
+
return !isFolder(attachment) && previewKind(attachment.path ?? "") === "html";
|
|
186
209
|
}
|
|
187
210
|
|
|
188
211
|
// Android's viewer has no PDF support.
|
|
@@ -197,7 +220,16 @@ export function opensInTab(attachment: Attachment, where: { platform: string }):
|
|
|
197
220
|
// The short name people recognize: a file or folder's own name, or a link's site.
|
|
198
221
|
export function shortName(attachment: Attachment): string {
|
|
199
222
|
if (attachment.url) {
|
|
200
|
-
try {
|
|
223
|
+
try {
|
|
224
|
+
return new URL(attachment.url).host.replace(/^www\./, "");
|
|
225
|
+
} catch {
|
|
226
|
+
return attachment.url;
|
|
227
|
+
}
|
|
201
228
|
}
|
|
202
|
-
return (
|
|
229
|
+
return (
|
|
230
|
+
(attachment.path ?? "")
|
|
231
|
+
.replace(/[\\/]+$/, "")
|
|
232
|
+
.split(/[\\/]/)
|
|
233
|
+
.pop() ?? ""
|
|
234
|
+
);
|
|
203
235
|
}
|
package/shared/blocks.ts
CHANGED
|
@@ -15,12 +15,15 @@ export function spans(text: string): Span[] {
|
|
|
15
15
|
result.push({ text: part.slice(1, -1), code: true });
|
|
16
16
|
return;
|
|
17
17
|
}
|
|
18
|
-
part
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
18
|
+
part
|
|
19
|
+
.replace(/!?\[([^\]]*)\]\([^)]*\)/g, "$1")
|
|
20
|
+
.split(/((?:\*\*|__).+?(?:\*\*|__))/)
|
|
21
|
+
.forEach((piece) => {
|
|
22
|
+
if (!piece) return;
|
|
23
|
+
const strong = /^(\*\*|__).+\1$/.test(piece);
|
|
24
|
+
const unmarked = (strong ? piece.slice(2, -2) : piece).replace(/(^|[\s(])[*_](\S(?:.*?\S)?)[*_](?=[\s).,;:!?]|$)/g, "$1$2");
|
|
25
|
+
result.push(strong ? { text: unmarked, strong } : { text: unmarked });
|
|
26
|
+
});
|
|
24
27
|
});
|
|
25
28
|
return result;
|
|
26
29
|
}
|
package/shared/dashboard.ts
CHANGED
|
@@ -152,20 +152,23 @@ export function parseProgress(text: string): { events: ParsedLine[]; issues: Fil
|
|
|
152
152
|
const issues: FileIssue[] = [];
|
|
153
153
|
// Whether or not the text ends in a newline, the last split piece is either
|
|
154
154
|
// empty or unfinished.
|
|
155
|
-
text
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
155
|
+
text
|
|
156
|
+
.split("\n")
|
|
157
|
+
.slice(0, -1)
|
|
158
|
+
.forEach((raw, index) => {
|
|
159
|
+
const line = index + 1;
|
|
160
|
+
if (!raw.trim()) return;
|
|
161
|
+
let json: unknown;
|
|
162
|
+
try {
|
|
163
|
+
json = JSON.parse(raw);
|
|
164
|
+
} catch {
|
|
165
|
+
issues.push({ line, reason: "not valid JSON" });
|
|
166
|
+
return;
|
|
167
|
+
}
|
|
168
|
+
const parsed = eventSchema.safeParse(json);
|
|
169
|
+
if (parsed.success) events.push({ line, event: parsed.data });
|
|
170
|
+
else issues.push({ line, reason: describeInvalid(json) });
|
|
171
|
+
});
|
|
169
172
|
return { events, issues };
|
|
170
173
|
}
|
|
171
174
|
|
|
@@ -209,7 +212,10 @@ export function latestRun(events: ParsedLine[]): ParsedLine[] {
|
|
|
209
212
|
// Folds the events of the latest run into what the panel shows at `now`, how
|
|
210
213
|
// long that stays true while the events don't change, and the ids new items get.
|
|
211
214
|
// Everything before the last `run.start` belongs to earlier runs and is ignored.
|
|
212
|
-
export function reduceProgress(
|
|
215
|
+
export function reduceProgress(
|
|
216
|
+
parsed: { events: ParsedLine[]; issues: FileIssue[] },
|
|
217
|
+
now: Date,
|
|
218
|
+
): { dashboard: Dashboard; validUntil: number; nextIds: NextIds } {
|
|
213
219
|
const time = clock(now);
|
|
214
220
|
const events = latestRun(parsed.events);
|
|
215
221
|
const issues = [...parsed.issues];
|
|
@@ -358,9 +364,7 @@ export function reduceProgress(parsed: { events: ParsedLine[]; issues: FileIssue
|
|
|
358
364
|
const list = [...tickets.values()];
|
|
359
365
|
for (const ticket of list) {
|
|
360
366
|
const other = ticket.waitsFor ? tickets.get(ticket.waitsFor) : undefined;
|
|
361
|
-
ticket.waitingFor = other && !isSettled(other) && !isSettled(ticket)
|
|
362
|
-
? { id: other.id, title: other.title }
|
|
363
|
-
: undefined;
|
|
367
|
+
ticket.waitingFor = other && !isSettled(other) && !isSettled(ticket) ? { id: other.id, title: other.title } : undefined;
|
|
364
368
|
}
|
|
365
369
|
const itemLabel = run?.itemLabel ?? "Ticket";
|
|
366
370
|
const stuck = stuckItems(list, [...activity.values()], [...manualStuck.values()], time.reached);
|
|
@@ -368,9 +372,7 @@ export function reduceProgress(parsed: { events: ParsedLine[]; issues: FileIssue
|
|
|
368
372
|
const open = allQuestions.filter((question) => !question.answer);
|
|
369
373
|
// A finished run has nothing left to stall, so it never goes stale.
|
|
370
374
|
const finished = Boolean(run?.finished) || (list.length > 0 && list.every(isSettled));
|
|
371
|
-
const answered = allQuestions
|
|
372
|
-
.filter((question) => question.answer)
|
|
373
|
-
.sort((a, b) => Date.parse(b.answer!.at) - Date.parse(a.answer!.at));
|
|
375
|
+
const answered = allQuestions.filter((question) => question.answer).sort((a, b) => Date.parse(b.answer!.at) - Date.parse(a.answer!.at));
|
|
374
376
|
const dashboard: Dashboard = {
|
|
375
377
|
run,
|
|
376
378
|
updatedAt,
|
|
@@ -388,17 +390,27 @@ export function reduceProgress(parsed: { events: ParsedLine[]; issues: FileIssue
|
|
|
388
390
|
tickets: list,
|
|
389
391
|
issues: issues.sort((a, b) => a.line - b.line),
|
|
390
392
|
};
|
|
391
|
-
return {
|
|
393
|
+
return {
|
|
394
|
+
dashboard,
|
|
395
|
+
validUntil: time.validUntil,
|
|
396
|
+
nextIds: nextIds(
|
|
397
|
+
parsed.events.map(({ event }) => event),
|
|
398
|
+
events.map(({ event }) => event),
|
|
399
|
+
),
|
|
400
|
+
};
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
function idsOf(events: ProgressEvent[], type: ProgressEvent["type"]): string[] {
|
|
404
|
+
return events.flatMap((event) => (event.type === type && "id" in event ? [event.id] : []));
|
|
392
405
|
}
|
|
393
406
|
|
|
394
407
|
function nextIds(all: ProgressEvent[], inRun: ProgressEvent[]): NextIds {
|
|
395
|
-
const used = (events: ProgressEvent[], type: ProgressEvent["type"]) => events.flatMap((event) => (event.type === type && "id" in event ? [event.id] : []));
|
|
396
408
|
return {
|
|
397
|
-
ticket: nextId("T",
|
|
398
|
-
question: nextId("Q",
|
|
399
|
-
deliverable: nextId("D",
|
|
400
|
-
activity: nextId("A",
|
|
401
|
-
stuck: nextId("S",
|
|
409
|
+
ticket: nextId("T", idsOf(inRun, "ticket.add")),
|
|
410
|
+
question: nextId("Q", idsOf(all, "question.ask"), 1),
|
|
411
|
+
deliverable: nextId("D", idsOf(inRun, "deliverable.add"), 1),
|
|
412
|
+
activity: nextId("A", idsOf(inRun, "activity.add"), 1),
|
|
413
|
+
stuck: nextId("S", idsOf(inRun, "stuck.set"), 1),
|
|
402
414
|
};
|
|
403
415
|
}
|
|
404
416
|
|
|
@@ -425,7 +437,12 @@ function lastTicketUpdate(ticket: Ticket, activity: Activity[]): number {
|
|
|
425
437
|
);
|
|
426
438
|
}
|
|
427
439
|
|
|
428
|
-
function stuckItems(
|
|
440
|
+
function stuckItems(
|
|
441
|
+
tickets: Ticket[],
|
|
442
|
+
activity: Activity[],
|
|
443
|
+
manual: Array<{ id: string; reason: string; ticket?: string; since: string }>,
|
|
444
|
+
reached: (at: number) => boolean,
|
|
445
|
+
): StuckItem[] {
|
|
429
446
|
const items: StuckItem[] = [];
|
|
430
447
|
for (const ticket of tickets) {
|
|
431
448
|
// Blocked only on another ticket in the run is waiting, not stuck.
|
package/shared/events.ts
CHANGED
|
@@ -121,16 +121,18 @@ export const eventSchema = z.discriminatedUnion("type", [
|
|
|
121
121
|
}),
|
|
122
122
|
z.object({ ...base, type: z.literal("question.remove"), id: text }),
|
|
123
123
|
// `path` is relative to the worktree root, or absolute when outside it.
|
|
124
|
-
z
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
124
|
+
z
|
|
125
|
+
.object({
|
|
126
|
+
...base,
|
|
127
|
+
type: z.literal("deliverable.add"),
|
|
128
|
+
id: text,
|
|
129
|
+
title: text,
|
|
130
|
+
path: text.optional(),
|
|
131
|
+
url: httpUrl.optional(),
|
|
132
|
+
ticket: text.optional(),
|
|
133
|
+
kind: z.enum(DELIVERABLE_KINDS).optional(),
|
|
134
|
+
})
|
|
135
|
+
.refine((event) => Boolean(event.path) !== Boolean(event.url), "A deliverable has a path or a URL"),
|
|
134
136
|
z.object({ ...base, type: z.literal("deliverable.remove"), id: text }),
|
|
135
137
|
z.object({ ...base, type: z.literal("activity.add"), id: text, text, ticket: text.optional() }),
|
|
136
138
|
z.object({ ...base, type: z.literal("activity.update"), id: text, text }),
|
package/shared/markdown.ts
CHANGED
|
@@ -42,7 +42,9 @@ export interface FileLocation {
|
|
|
42
42
|
}
|
|
43
43
|
|
|
44
44
|
export function prepareMarkdown(bytes: Uint8Array, truncated: boolean, where: FileLocation): string {
|
|
45
|
-
const text = previewText(bytes, truncated)
|
|
45
|
+
const text = previewText(bytes, truncated)
|
|
46
|
+
.replace(/^\uFEFF/, "")
|
|
47
|
+
.replace(/\r\n?/g, "\n");
|
|
46
48
|
return rewriteTargets(fenceFrontMatter(text), where);
|
|
47
49
|
}
|
|
48
50
|
|
|
@@ -195,7 +197,8 @@ function rewriteTargets(source: string, where: FileLocation): string {
|
|
|
195
197
|
if (!lines[index].definition) continue;
|
|
196
198
|
const lineText = source.slice(lines[index].start, lines[index].end);
|
|
197
199
|
const target = definitionTarget(lineText);
|
|
198
|
-
if (target && !mask[lines[index].start - offset + target.start])
|
|
200
|
+
if (target && !mask[lines[index].start - offset + target.start])
|
|
201
|
+
add(lines[index].start - offset + target.start, lines[index].start - offset + target.end, target.target, false);
|
|
199
202
|
}
|
|
200
203
|
for (const tag of text.matchAll(/<(img|a)\b[^>]*>/gi)) {
|
|
201
204
|
if (mask[tag.index]) continue;
|
|
@@ -428,11 +431,15 @@ function blockUnits(body: string): { units: Unit[]; definitions: Line[] } {
|
|
|
428
431
|
const text = body.slice(line.start, line.end);
|
|
429
432
|
const current = unit as Unit | null;
|
|
430
433
|
const inFence = line.kind === "fence-body" || line.kind === "fence-close";
|
|
431
|
-
const continues =
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
434
|
+
const continues =
|
|
435
|
+
current !== null &&
|
|
436
|
+
((current.kind === "fence" && inFence) ||
|
|
437
|
+
(current.kind === "code" && line.kind === "indented-code") ||
|
|
438
|
+
(current.kind === "item" &&
|
|
439
|
+
!line.topLevelItem &&
|
|
440
|
+
line.kind !== "blank" &&
|
|
441
|
+
(inFence || indentOf(text) >= 2 || (!afterBlank && line.kind === "text" && !line.headingLevel))) ||
|
|
442
|
+
(current.kind === "paragraph" && line.kind === "text" && !line.headingLevel && !line.topLevelItem && !line.definition));
|
|
436
443
|
if (continues) {
|
|
437
444
|
current.end = line.end;
|
|
438
445
|
current.lines.push(line);
|
|
@@ -468,7 +475,8 @@ function cutFirstBlock(body: string, first: Unit, maxChars: number): { end: numb
|
|
|
468
475
|
|
|
469
476
|
function visibleLength(body: string, unit: Unit, maxChars: number): number {
|
|
470
477
|
const text = body.slice(unit.start, unit.end);
|
|
471
|
-
if (unit.kind === "fence" || unit.kind === "code")
|
|
478
|
+
if (unit.kind === "fence" || unit.kind === "code")
|
|
479
|
+
return (unit.kind === "fence" ? unit.lines.slice(1, -1) : unit.lines).reduce((sum, line) => sum + line.end - line.start, 0);
|
|
472
480
|
if (/!\[[^\]]*\]\(|<img\b/i.test(text)) return maxChars;
|
|
473
481
|
return text
|
|
474
482
|
.replace(/<!--[\s\S]*?-->|<[^>]+>/g, "")
|
package/shared/question-modes.ts
CHANGED
|
@@ -19,13 +19,15 @@ interface QuestionModeRules {
|
|
|
19
19
|
state(big: boolean): QuestionState;
|
|
20
20
|
}
|
|
21
21
|
|
|
22
|
-
const HOLD =
|
|
22
|
+
const HOLD =
|
|
23
|
+
"Build no option before the answer, not even your recommendation. Work on everything the question doesn't affect. If nothing else is left, end your turn, tell the user in chat which questions you're waiting on, and leave the run open.";
|
|
23
24
|
|
|
24
25
|
export const QUESTION_MODE_RULES: Record<QuestionMode, QuestionModeRules> = {
|
|
25
26
|
stop: {
|
|
26
27
|
label: "Stop",
|
|
27
28
|
hint: "Agents stop all work when they ask, until you answer.",
|
|
28
|
-
askRule: () =>
|
|
29
|
+
askRule: () =>
|
|
30
|
+
"Ask any other questions you already have, then stop all work and resume only after an answer. End your turn now, tell the user in chat which questions you're waiting on, and leave the run open.",
|
|
29
31
|
state: () => "stopped",
|
|
30
32
|
},
|
|
31
33
|
"work-around": {
|
|
@@ -37,9 +39,10 @@ export const QUESTION_MODE_RULES: Record<QuestionMode, QuestionModeRules> = {
|
|
|
37
39
|
"keep-building": {
|
|
38
40
|
label: "Keep building",
|
|
39
41
|
hint: "Agents build their recommended option while you decide, and change course if you pick another. Big questions still wait.",
|
|
40
|
-
askRule: ({ recommend, big }) =>
|
|
41
|
-
|
|
42
|
-
|
|
42
|
+
askRule: ({ recommend, big }) =>
|
|
43
|
+
big
|
|
44
|
+
? `This question is big, so hold it like Work around. ${HOLD}`
|
|
45
|
+
: `Build ${recommend} now and keep working. If the user picks another option, undo the work built on ${recommend} and build their choice first. If the rest of the work is done while it's still open, leave it open and tell the user in chat; don't withdraw it.`,
|
|
43
46
|
state: (big) => (big ? "waiting" : "building"),
|
|
44
47
|
},
|
|
45
48
|
};
|