@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.
Files changed (67) hide show
  1. package/README.md +24 -17
  2. package/client/attachments.tsx +122 -28
  3. package/client/blocks.tsx +33 -8
  4. package/client/chat-row-view.tsx +19 -6
  5. package/client/chat-row.ts +13 -2
  6. package/client/dashboard-query.ts +9 -4
  7. package/client/deliverables.tsx +82 -17
  8. package/client/dialog-stack.tsx +104 -42
  9. package/client/directory.ts +1 -3
  10. package/client/errors.ts +6 -1
  11. package/client/history.tsx +233 -79
  12. package/client/i18n.ts +10 -5
  13. package/client/launcher.tsx +57 -15
  14. package/client/locales/en.ts +28 -22
  15. package/client/locales/zh-CN.ts +18 -17
  16. package/client/messages.ts +1 -0
  17. package/client/motion.tsx +38 -14
  18. package/client/panel-opened.ts +3 -1
  19. package/client/panel.tsx +442 -160
  20. package/client/pdf.tsx +18 -4
  21. package/client/pieces.ts +6 -1
  22. package/client/pill-popover.tsx +108 -23
  23. package/client/pills.tsx +35 -12
  24. package/client/preview.tsx +265 -79
  25. package/client/questions.tsx +173 -52
  26. package/client/row.tsx +14 -3
  27. package/client/section-title.tsx +11 -2
  28. package/client/settings.tsx +76 -24
  29. package/client/show-more.tsx +23 -6
  30. package/client/spinner.tsx +11 -7
  31. package/client/status-badge.tsx +9 -7
  32. package/client/tabs.tsx +98 -20
  33. package/client/ticket-dialog.tsx +241 -73
  34. package/client/time-bar.tsx +5 -2
  35. package/client/video.tsx +15 -6
  36. package/client/whats-new.tsx +94 -36
  37. package/client/when.tsx +2 -1
  38. package/client/write-up.tsx +79 -12
  39. package/dist/server/cli.js +199 -101
  40. package/index.client.tsx +28 -13
  41. package/index.server.ts +19 -1
  42. package/package.json +15 -14
  43. package/server/cli.ts +72 -32
  44. package/server/dashboard.ts +9 -13
  45. package/server/launcher.ts +33 -17
  46. package/server/lock.ts +14 -7
  47. package/server/open.ts +32 -98
  48. package/server/progress-file.ts +6 -6
  49. package/server/question-mode.ts +12 -6
  50. package/server/skill.ts +22 -16
  51. package/server/store.ts +26 -41
  52. package/server/system.ts +113 -0
  53. package/server/tool.ts +89 -44
  54. package/server/whats-new.ts +25 -13
  55. package/shared/attachments.ts +40 -8
  56. package/shared/blocks.ts +9 -6
  57. package/shared/dashboard.ts +46 -29
  58. package/shared/events.ts +12 -10
  59. package/shared/markdown.ts +16 -8
  60. package/shared/question-modes.ts +8 -5
  61. package/shared/rpc.ts +9 -3
  62. package/shared/show.ts +20 -8
  63. package/shared/ticket-story.ts +1 -3
  64. package/skills/paseo-progress/SKILL.md +1 -0
  65. package/tsconfig.json +3 -10
  66. package/client/page-opener.ts +0 -9
  67. 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>(tool: Omit<Tool, "input" | "execute"> & {
18
- input: Input;
19
- execute(input: z.output<Input>, context: AgentToolContext): Promise<AgentToolResult>;
20
- }): Tool {
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(/--([a-z]+(?:-[a-z]+)*)/g, (_, flag: string) =>
43
- FIELD_FOR_FLAG[flag] ?? flag.replace(/-([a-z])/g, (_m, letter: string) => letter.toUpperCase()));
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 source = z.string().describe("Spec file path or http(s) link where the ticket is written up");
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("Start run", "start",
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
- progress_ticket_add: commandTool("Add ticket", "ticket add",
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: source.optional(),
84
+ source: ticketSource.optional(),
76
85
  }),
77
- ({ title, estimateMinutes, status, waitsFor, source }) => commandArgs("ticket add", [title], { estimate: estimateMinutes, status, "waits-for": waitsFor, source })),
78
- progress_ticket_update: commandTool("Update ticket", "ticket update",
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
- progress_question_ask: commandTool("Ask question", "question ask",
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(z.object({
96
- letter: z.string().describe("A, B, C, ..."),
97
- label: z.string(),
98
- consequence: z.string().describe("What picking it means for the user"),
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.array(z.object({
104
- target: z.string().describe("A file path or http(s) link, like a screenshot"),
105
- label: z.string().optional(),
106
- })).optional(),
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 }) => commandArgs("question ask", [title, question], {
110
- option: options.map(({ letter, label, consequence }) => `${letter}=${label} | ${consequence}`),
111
- recommend,
112
- big,
113
- background,
114
- file: files?.map(({ target, label }) => (label ? `${target}=${label}` : target)),
115
- ticket,
116
- })),
117
- progress_question_answer: commandTool("Record answer", "question answer",
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
- progress_deliverable_add: commandTool("Add deliverable", "deliverable add",
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
- progress_activity_add: commandTool("Log activity", "activity add",
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
- progress_ticker: commandTool("Set ticker", "ticker set",
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
- progress_finish: commandTool("Finish run", "finish",
140
- z.object({ outcome: z.string().describe("One line") }),
141
- ({ outcome }) => commandArgs("finish", [outcome])),
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
- execute: ({ args }, context) => run(args, context),
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
 
@@ -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, writeAtomic } from "./store.ts";
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), "whats-new.json");
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(note) }))
48
- .sort((a, b) => compareVersions(b.version, a.version));
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 note(bullet: string): WhatsNewNote {
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.split(/(`[^`]+`)/).map((part, index) => index % 2 ? part : curly(part)).join("");
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.replace(/(^|[\s([{])"/g, "$1\u201c").replace(/"/g, "\u201d").replace(/(^|[\s([{])'/g, "$1\u2018").replace(/'/g, "\u2019");
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).map((span) => span.text).join("");
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 writeAtomic(whatsNewPath(home), `${JSON.stringify({ seen: version })}\n`);
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) => seen === null
119
- ? release.version === version
120
- : compareVersions(release.version, seen) > 0 && compareVersions(release.version, version) <= 0);
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 };
@@ -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 { ref, title: file.label ?? (file.path ?? file.url ?? "").replace(/[\\/]+$/, "").split(/[\\/]/).pop() ?? "", path: file.path, url: file.url };
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] : /[\/]$/.test(attachment.path) ? "Folder" : "File";
202
+ return kind ? PREVIEW_ICON[kind] : attachment.path.endsWith("/") ? "Folder" : "File";
180
203
  }
181
204
 
182
- // An HTML page is too wide for the dialog, so where the host can't open it the
183
- // desktop app gives it a screen of its own. The phone apps can't draw one.
184
- export function opensAsPage(attachment: Attachment, where: { browser: boolean }): boolean {
185
- return where.browser && Boolean(attachment.path) && previewKind(attachment.path!) === "html";
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 { return new URL(attachment.url).host.replace(/^www\./, ""); } catch { return attachment.url; }
223
+ try {
224
+ return new URL(attachment.url).host.replace(/^www\./, "");
225
+ } catch {
226
+ return attachment.url;
227
+ }
201
228
  }
202
- return (attachment.path ?? "").replace(/[\\/]+$/, "").split(/[\\/]/).pop() ?? "";
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.replace(/!?\[([^\]]*)\]\([^)]*\)/g, "$1").split(/((?:\*\*|__).+?(?:\*\*|__))/).forEach((piece) => {
19
- if (!piece) return;
20
- const strong = /^(\*\*|__).+\1$/.test(piece);
21
- const text = (strong ? piece.slice(2, -2) : piece).replace(/(^|[\s(])[*_](\S(?:.*?\S)?)[*_](?=[\s).,;:!?]|$)/g, "$1$2");
22
- result.push(strong ? { text, strong } : { text });
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
  }
@@ -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.split("\n").slice(0, -1).forEach((raw, index) => {
156
- const line = index + 1;
157
- if (!raw.trim()) return;
158
- let json: unknown;
159
- try {
160
- json = JSON.parse(raw);
161
- } catch {
162
- issues.push({ line, reason: "not valid JSON" });
163
- return;
164
- }
165
- const parsed = eventSchema.safeParse(json);
166
- if (parsed.success) events.push({ line, event: parsed.data });
167
- else issues.push({ line, reason: describeInvalid(json) });
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(parsed: { events: ParsedLine[]; issues: FileIssue[] }, now: Date): { dashboard: Dashboard; validUntil: number; nextIds: NextIds } {
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 { dashboard, validUntil: time.validUntil, nextIds: nextIds(parsed.events.map(({ event }) => event), events.map(({ event }) => event)) };
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", used(inRun, "ticket.add")),
398
- question: nextId("Q", used(all, "question.ask"), 1),
399
- deliverable: nextId("D", used(inRun, "deliverable.add"), 1),
400
- activity: nextId("A", used(inRun, "activity.add"), 1),
401
- stuck: nextId("S", used(inRun, "stuck.set"), 1),
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(tickets: Ticket[], activity: Activity[], manual: Array<{ id: string; reason: string; ticket?: string; since: string }>, reached: (at: number) => boolean): StuckItem[] {
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.object({
125
- ...base,
126
- type: z.literal("deliverable.add"),
127
- id: text,
128
- title: text,
129
- path: text.optional(),
130
- url: httpUrl.optional(),
131
- ticket: text.optional(),
132
- kind: z.enum(DELIVERABLE_KINDS).optional(),
133
- }).refine((event) => Boolean(event.path) !== Boolean(event.url), "A deliverable has a path or a URL"),
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 }),
@@ -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).replace(/^\uFEFF/, "").replace(/\r\n?/g, "\n");
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]) add(lines[index].start - offset + target.start, lines[index].start - offset + target.end, target.target, false);
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 = current !== null && (
432
- (current.kind === "fence" && inFence)
433
- || (current.kind === "code" && line.kind === "indented-code")
434
- || (current.kind === "item" && !line.topLevelItem && line.kind !== "blank" && (inFence || indentOf(text) >= 2 || (!afterBlank && line.kind === "text" && !line.headingLevel)))
435
- || (current.kind === "paragraph" && line.kind === "text" && !line.headingLevel && !line.topLevelItem && !line.definition));
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") return (unit.kind === "fence" ? unit.lines.slice(1, -1) : unit.lines).reduce((sum, line) => sum + line.end - line.start, 0);
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, "")
@@ -19,13 +19,15 @@ interface QuestionModeRules {
19
19
  state(big: boolean): QuestionState;
20
20
  }
21
21
 
22
- const HOLD = "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.";
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: () => "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
+ 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 }) => big
41
- ? `This question is big, so hold it like Work around. ${HOLD}`
42
- : `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.`,
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
  };