@stevecastaneda/paseo-progress 1.11.0 → 1.12.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/OVERVIEW.md CHANGED
@@ -1,6 +1,6 @@
1
- Progress gives each worktree a live dashboard for long agent jobs. The agent records its tickets or steps with a `paseo-progress` command, along with what's stuck, questions for you and finished work. The Progress panel in Explorer shows all of it. Needs Paseo 0.11 or later, and Node.js 22.18 or later on the computer running the Paseo daemon.
1
+ Progress gives each worktree a live dashboard for long agent jobs. The agent records its tickets or steps with a `paseo-progress` command, along with what's stuck, questions for you and finished work. The Progress panel in Explorer shows all of it. Needs Paseo 0.13 or later, and Node.js 22.18 or later on the computer running the Paseo daemon.
2
2
 
3
- The panel shows how much of the estimated work is done, what the agent is doing right now, and where each ticket stands. A ticket turns stuck when it's blocked or runs past its estimate without an update. Press a ticket to see its timeline, activity, questions and deliverables. Images, videos and text files the agent records open in a preview inside Paseo, and a folder opens as a list of its files. Videos play only in the desktop app. Any other file opens in an app on the computer running the Paseo daemon, and so does a previewed file when you press Open. Open is hidden when that computer can't open files, like a Linux server with no desktop. That's true from your phone too. The app opens on that computer, not on the phone.
3
+ The panel shows how much of the estimated work is done, what the agent is doing right now, and where each ticket stands. A ticket turns stuck when it's blocked or runs past its estimate without an update. Press a ticket to see its timeline, activity, questions and deliverables. Images, videos and text files the agent records open in a preview inside Paseo, with Markdown formatted, and a folder opens as a list of its files. Videos play only in the desktop app. Any other file opens in an app on the computer running the Paseo daemon, and so does a previewed file when you press Open. Open is hidden when that computer can't open files, like a Linux server with no desktop. That's true from your phone too. The app opens on that computer, not on the phone.
4
4
 
5
5
  When the agent needs a decision, it asks a numbered question with lettered options and says which one it recommends. A pill above the message box counts open questions and stuck work. Each option has a Copy button. Paste the answer into the chat and the agent records it. In Settings you choose what the agent does while it waits: stop, work around the question, or keep building on its recommendation.
6
6
 
package/README.md CHANGED
@@ -2,7 +2,7 @@
2
2
 
3
3
  # Progress
4
4
 
5
- A plugin for Paseo 0.11 and later that gives each worktree a live progress dashboard. Agents record progress with the `paseo-progress` command, which adds one line per change to the worktree's progress file. That file lives outside the repo, so git never sees it. The **Progress** panel draws the dashboard from that file and updates by itself.
5
+ A plugin for Paseo 0.13 and later that gives each worktree a live progress dashboard. Agents record progress with the `paseo-progress` command, which adds one line per change to the worktree's progress file. That file lives outside the repo, so git never sees it. The **Progress** panel draws the dashboard from that file and updates by itself.
6
6
 
7
7
  <img src="images/1-panel.png" alt="The Progress panel in Explorer" width="440">
8
8
 
@@ -24,7 +24,7 @@ The panel lives in Explorer, beside the agent chat, so you can watch both. Open
24
24
 
25
25
  <img src="images/4-pill.png" alt="The pill above the message box, showing two questions waiting" width="600">
26
26
 
27
- On phones with Paseo 0.13 or later, Progress opens in Explorer too. Older phone apps have no Explorer, so there it opens as a tab of its own. On any phone, the pill stays above the message box while a run is open, so there's always a way in. SVG files and videos preview on desktop; on phones they open on the daemon host.
27
+ On phones, Progress opens in Explorer too. On any phone, the pill stays above the message box while a run is open, so there's always a way in. SVG files and videos preview on desktop; on phones they open on the daemon host.
28
28
 
29
29
  The first time a run starts in a worktree whose Progress panel has never been opened, the pill shows a spinner and reads **Progress** instead; pressing it opens the panel. Once the panel has been opened there, the plugin writes an empty `progress-panel-opened` file next to the progress file and the nudge never comes back, even for later runs. It doesn't open the panel by itself because Paseo switches to a workspace to open its panel, which would pull you away from whatever you're looking at.
30
30
 
@@ -120,7 +120,7 @@ paseo-progress finish "Snapshots ship for all four tables"
120
120
  - A working ticket is stuck once it goes longer than its estimate without an update, timed from its last status, stage or note change, or activity tagged to it. The ticket's dialog still shows total time worked against the estimate.
121
121
  - `question ask` needs `--recommend`, the agent's pick. The question dialog tags that option "Recommended". Its output tells the agent the question mode and what to do until the answer. `--big "<why>"` marks a big question, which waits for the answer even in Keep building. `start` and `show` also print the mode.
122
122
  - Paseo keeps the question mode in its own settings, which only the plugin's server can read, so the server copies it to `~/.local/state/paseo-progress/settings.json` for the command. Without that file, for example while the plugin is off, the command uses Work around.
123
- - Deliverable paths are stored relative to the worktree root. Web links open in Paseo's browser. Pressing an image (PNG, JPEG, GIF, WebP, up to 10 MB) or text deliverable (Markdown, text, logs, JSON, YAML, CSV; the first 256 KB) previews it in a dialog inside Paseo, which also works from a phone; **Open** there opens the file in its default app. **Open** shows only when the computer running Paseo can open files. A Linux server with no desktop can't, so there a file Paseo can't preview copies its path and says why. A video (MP4, MOV, WebM, up to 200 MB) plays in the same dialog in the desktop app. When there are several files, **Previous** and **Next** step through them, and so do the left and right arrow keys. Pressing a folder lists its files in the same dialog. Press a file to preview it, or a folder inside to list that one, and **Back** returns to the list. Pressing any other local deliverable opens it with its default app on the machine running the Paseo daemon (for example HTML in your browser). On macOS, **Open** sends text files to your default browser. Only recorded deliverables inside the worktree open this way; otherwise the path is copied. The copy icon on each row copies the path.
123
+ - Deliverable paths are stored relative to the worktree root. Web links open in Paseo's browser. Pressing an image (PNG, JPEG, GIF, WebP, up to 10 MB) or text deliverable (Markdown, text, logs, JSON, YAML, CSV; the first 256 KB) previews it in a dialog inside Paseo, which also works from a phone. Markdown shows formatted, with links and images relative to the file's own folder; **Open** there opens the file in its default app. **Open** shows only when the computer running Paseo can open files. A Linux server with no desktop can't, so there a file Paseo can't preview copies its path and says why. A video (MP4, MOV, WebM, up to 200 MB) plays in the same dialog in the desktop app. When there are several files, **Previous** and **Next** step through them, and so do the left and right arrow keys. Pressing a folder lists its files in the same dialog. Press a file to preview it, or a folder inside to list that one, and **Back** returns to the list. Pressing any other local deliverable opens it with its default app on the machine running the Paseo daemon (for example HTML in your browser). On macOS, **Open** sends text files to your default browser. Only recorded deliverables inside the worktree open this way; otherwise the path is copied. The copy icon on each row copies the path.
124
124
  - Several agents can share one run. When another session adds a ticket, each agent sees it at the end of its next command's output (and in `show`), and the skill has it work that ticket in order after its current one. An agent only hears about tickets added since its own last update in the run, and only once.
125
125
  - Subagents record their own progress. Since Paseo 0.11, an agent waiting on background subagents shows as idle, so only the subagents' updates show the work is still going. The skill has the agent give each subagent the worktree path and ticket id, and the subagent records its stages and a ticker line on that worktree's dashboard.
126
126
  - `start` begins a fresh dashboard. Earlier runs stay in the file. Question references stay unique across runs.
@@ -145,6 +145,13 @@ The progress file, `progress.jsonl`, holds one JSON event per line, each with a
145
145
 
146
146
  ## Changelog
147
147
 
148
+ ### 1.12.0
149
+
150
+ - [Improved] **Markdown deliverables show formatted.** A `.md` or `.markdown` file now opens in the preview with its headings, lists, tables, code blocks and images drawn by Paseo's own Markdown view. An image with a relative path loads from the file's own folder. A link to another file in the worktree opens that file in Paseo, except on a phone, where Paseo doesn't open file links from a plugin's dialog. MDX files still show as plain text, because Paseo would hide their components.
151
+ - [Improved] **Ticket write-ups show formatted.** A ticket's write-up is drawn the same way. Its headings stay smaller than the ticket's title, and front matter stays out of the short version.
152
+ - [Fixed] **Long previews end on a whole line.** A text file over 256 KB used to stop wherever the 256 KB ran out, sometimes in the middle of a character. Now it stops at the last whole line, above the note that says the file goes on.
153
+ - [Improved] **Needs Paseo 0.13 or later.** Progress now uses Paseo 0.13's plugin interface and its Markdown view. On an older Paseo, the version you already have keeps working.
154
+
148
155
  ### 1.11.0
149
156
 
150
157
  - [Improved] **Agents can change their recommendation.** When you ask an agent about an open question and explaining it changes the agent's mind, it can now change its recommendation, and the question card shows the new one. In Keep building mode, the agent also switches the work it already built to the new recommendation.
@@ -1,5 +1,5 @@
1
1
  import { openExternalUrl, type PluginWorkspacePanelProps, useRpc } from "@getpaseo/plugin/client";
2
- import { copyText, Icon, useToast } from "@getpaseo/plugin/client/react-native";
2
+ import { copyText, Icon, useToast } from "@getpaseo/plugin/client/ui";
3
3
  import React from "react";
4
4
  import { Platform, Text, View } from "react-native";
5
5
  import { type Attachment, attachmentIcon, canPreview, opensAsPage, shortName } from "../shared/attachments";
@@ -0,0 +1,41 @@
1
+ import { type PluginWorkspacePanelProps } from "@getpaseo/plugin/client";
2
+ import React from "react";
3
+ import { Platform, Text, View } from "react-native";
4
+ import { type Block, type Span } from "../shared/blocks";
5
+
6
+ type Colors = PluginWorkspacePanelProps["theme"]["colors"];
7
+ const MONO = Platform.OS === "ios" ? "Menlo" : "monospace";
8
+
9
+ export function BlockView({ colors, block, first }: { colors: Colors; block: Block; first: boolean }) {
10
+ switch (block.kind) {
11
+ case "heading":
12
+ return (
13
+ // A step under the section's own 13px title: smaller and muted, set apart from the body by weight.
14
+ <Text accessibilityRole="header" selectable style={{ color: colors.foregroundMuted, fontSize: 12, lineHeight: 17, fontWeight: "600", marginTop: first ? 0 : 6 }}>
15
+ <Spans colors={colors} spans={block.spans} />
16
+ </Text>
17
+ );
18
+ case "item":
19
+ return (
20
+ <View style={{ flexDirection: "row", gap: 6, paddingStart: block.depth * 14 }}>
21
+ <Text style={{ minWidth: 10, color: colors.foregroundMuted, fontSize: 13, lineHeight: 19, fontVariant: ["tabular-nums"] }}>{block.marker}</Text>
22
+ <Text selectable style={{ flex: 1, color: colors.foreground, fontSize: 13, lineHeight: 19 }}><Spans colors={colors} spans={block.spans} /></Text>
23
+ </View>
24
+ );
25
+ case "code":
26
+ return (
27
+ <View style={{ padding: 8, borderRadius: 6, backgroundColor: colors.surface0 }}>
28
+ <Text selectable style={{ color: colors.foreground, fontFamily: MONO, fontSize: 12, lineHeight: 18 }}>{block.text}</Text>
29
+ </View>
30
+ );
31
+ default:
32
+ return <Text selectable style={{ color: colors.foreground, fontSize: 13, lineHeight: 19 }}><Spans colors={colors} spans={block.spans} /></Text>;
33
+ }
34
+ }
35
+
36
+ export function Spans({ colors, spans }: { colors: Colors; spans: Span[] }) {
37
+ return <>{spans.map((span, index) => span.code
38
+ ? <Text key={index} style={{ fontFamily: MONO, fontSize: 12, color: colors.foreground }}>{span.text}</Text>
39
+ : span.strong ? <Text key={index} style={{ fontWeight: "600" }}>{span.text}</Text>
40
+ : <React.Fragment key={index}>{span.text}</React.Fragment>)}</>;
41
+ }
@@ -1,5 +1,5 @@
1
1
  import type { PluginWorkspacePanelProps } from "@getpaseo/plugin/client";
2
- import { Icon } from "@getpaseo/plugin/client/react-native";
2
+ import { Icon } from "@getpaseo/plugin/client/ui";
3
3
  import React, { useState } from "react";
4
4
  import { Text, View } from "react-native";
5
5
  import type { Deliverable } from "../shared/dashboard";
@@ -1,5 +1,5 @@
1
1
  import type { PluginWorkspacePanelProps } from "@getpaseo/plugin/client";
2
- import { Icon, Modal } from "@getpaseo/plugin/client/react-native";
2
+ import { Icon, Modal } from "@getpaseo/plugin/client/ui";
3
3
  import React, { useEffect, useRef, useState } from "react";
4
4
  import { Platform } from "react-native";
5
5
  import { opensAsPage } from "../shared/attachments";
@@ -98,7 +98,7 @@ export function StackedDialog({ colors, root, onClose, questions = [], dashboard
98
98
  onOpenOnHost={() => { onClose(); void opener.openOnHost(preview.attachment); }}
99
99
  onCopyPath={() => void opener.copyPath(preview.attachment)} onBack={back}
100
100
  gallery={{ items: preview.group, onSelect: (attachment) => replaceTop({ ...preview, attachment, fullScreen: preview.fullScreen && previewKind(attachment.path ?? "") === "image", leftFullScreen: false }) }}
101
- onLong={setPreviewLong} fullScreen={preview.fullScreen} leftFullScreen={preview.leftFullScreen === true} onFullScreen={setFullScreen}
101
+ onLong={setPreviewLong} ownScroll={previewLong || preview.fullScreen} fullScreen={preview.fullScreen} leftFullScreen={preview.leftFullScreen === true} onFullScreen={setFullScreen}
102
102
  onOpenEntry={openAttachment} />
103
103
  ) : (
104
104
  <>
@@ -1,5 +1,5 @@
1
1
  import { type PluginWorkspacePanelProps, useRpc } from "@getpaseo/plugin/client";
2
- import { Icon, Modal, useToast } from "@getpaseo/plugin/client/react-native";
2
+ import { Icon, Modal, useToast } from "@getpaseo/plugin/client/ui";
3
3
  import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
4
4
  import React, { useState } from "react";
5
5
  import { ActivityIndicator, ScrollView, Text, TextInput, View } from "react-native";
@@ -1,5 +1,5 @@
1
1
  import { type PluginWorkspacePanelProps, useRpc } from "@getpaseo/plugin/client";
2
- import { Icon, useToast } from "@getpaseo/plugin/client/react-native";
2
+ import { Icon, useToast } from "@getpaseo/plugin/client/ui";
3
3
  import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
4
4
  import React from "react";
5
5
  import { Text, View } from "react-native";
package/client/panel.tsx CHANGED
@@ -1,5 +1,5 @@
1
1
  import { type PluginWorkspacePanelProps, useRpc, useWorkspace } from "@getpaseo/plugin/client";
2
- import { Icon } from "@getpaseo/plugin/client/react-native";
2
+ import { Icon } from "@getpaseo/plugin/client/ui";
3
3
  import { DeliverablesList } from "./deliverables";
4
4
  import { LauncherBanner, SkillBanner } from "./launcher";
5
5
  import { WhatsNewBanner } from "./whats-new";
@@ -1,5 +1,5 @@
1
1
  import { type PluginButtonContentProps, useWorkspace } from "@getpaseo/plugin/client";
2
- import { Icon, ScrollView } from "@getpaseo/plugin/client/react-native";
2
+ import { Icon, ScrollView } from "@getpaseo/plugin/client/ui";
3
3
  import React, { useRef, useState } from "react";
4
4
  import { Text, View } from "react-native";
5
5
  import type { Dashboard } from "../shared/dashboard";
package/client/pills.tsx CHANGED
@@ -1,5 +1,5 @@
1
1
  import type { PluginButtonContentProps, PluginButtonIconProps, PluginButtonRegistration, PluginClientContext } from "@getpaseo/plugin/client";
2
- import { Icon } from "@getpaseo/plugin/client/react-native";
2
+ import { Icon } from "@getpaseo/plugin/client/ui";
3
3
  import React, { useEffect, useSyncExternalStore } from "react";
4
4
  import { Platform } from "react-native";
5
5
  import { type AttentionResult, getAttention } from "../shared/rpc";
@@ -18,17 +18,8 @@ export const PILL_POLL_MS = 5_000;
18
18
  // still trigger a check, at most once a poll interval.
19
19
  export const IDLE_POLL_EVERY = 6;
20
20
 
21
- // Phones on Paseo before 0.13 have no Explorer, so there the panel is a tab of
22
- // its own. addFilePanel arrived in 0.13, the release that gave phones Explorer.
23
- // The tab has its own id: Paseo 0.10.1 reopens an existing tab where it
24
- // already is, and earlier versions of this plugin left a "progress" tab in the
25
- // phone's hidden Explorer pane, which a plugin can't move or close.
26
- type ProgressPanelPlacement = { id: "progress"; location: "explorer" } | { id: "progress-tab"; location: "workspace" };
27
-
28
- export function progressPanel(client: PluginClientContext): ProgressPanelPlacement {
29
- return Platform.OS === "web" || "addFilePanel" in client
30
- ? { id: "progress", location: "explorer" }
31
- : { id: "progress-tab", location: "workspace" };
21
+ export function progressPanel(): { id: "progress"; location: "explorer" } {
22
+ return { id: "progress", location: "explorer" };
32
23
  }
33
24
 
34
25
  type Workspace = { id: string; workspaceDirectory?: string; projectRootPath: string };
@@ -87,7 +78,7 @@ export function contributePills(client: PluginClientContext) {
87
78
  );
88
79
  }
89
80
 
90
- const panel = progressPanel(client);
81
+ const panel = progressPanel();
91
82
  const openPanel = (workspaceId: string) => {
92
83
  client.openPanel(panel.id, { workspaceId, location: panel.location });
93
84
  };
@@ -1,8 +1,8 @@
1
1
  import { type PluginWorkspacePanelProps, useRpc } from "@getpaseo/plugin/client";
2
- import { Icon, ScrollView } from "@getpaseo/plugin/client/react-native";
2
+ import { Icon, Markdown, ScrollView } from "@getpaseo/plugin/client/ui";
3
3
  import { useQuery, useQueryClient } from "@tanstack/react-query";
4
- import React, { useEffect, useRef, useState } from "react";
5
- import { ActivityIndicator, Image, Platform, Pressable, Text, useWindowDimensions, View, type ViewStyle } from "react-native";
4
+ import React, { useEffect, useMemo, useRef, useState } from "react";
5
+ import { ActivityIndicator, Image, type LayoutChangeEvent, Platform, Pressable, Text, useWindowDimensions, View, type ViewStyle } from "react-native";
6
6
  import { ATTACHMENT_ROW, type Attachment, AttachmentList, isPreviewable } from "./attachments";
7
7
  import { errorText } from "./errors";
8
8
  import { folderAttachments, isFolder, previewKind } from "../shared/attachments";
@@ -39,8 +39,9 @@ type PreviewProps = {
39
39
 
40
40
  // `onLong` says when the file is too tall for the dialog, so the host turns off
41
41
  // its own scrolling and the file scrolls with the actions pinned below.
42
- export function PreviewContent({ colors, attachment, workspaceId, workspaceDirectory, onOpenOnHost, onCopyPath, onBack, gallery, onOpenEntry, onLong, fullScreen, leftFullScreen, onFullScreen }: PreviewProps & {
42
+ export function PreviewContent({ colors, attachment, workspaceId, workspaceDirectory, onOpenOnHost, onCopyPath, onBack, gallery, onOpenEntry, onLong, ownScroll, fullScreen, leftFullScreen, onFullScreen }: PreviewProps & {
43
43
  onLong(long: boolean): void;
44
+ ownScroll: boolean;
44
45
  fullScreen: boolean;
45
46
  leftFullScreen: boolean;
46
47
  onFullScreen(on: boolean): void;
@@ -146,22 +147,40 @@ export function PreviewContent({ colors, attachment, workspaceId, workspaceDirec
146
147
  const room = roomForContent(windowHeight);
147
148
  const maxMediaHeight = preview.data?.kind === "video" ? Math.min(640, Math.max(200, room)) : 640;
148
149
  const mediaStyle = { width: "100%", aspectRatio, maxHeight: maxMediaHeight, borderRadius: 6, ...imageOutline(colors), backgroundColor: colors.surface1, opacity: stepping ? 0.6 : 1 } as const;
150
+ // Markdown's height depends on the user's text size, which a plugin can't read,
151
+ // so only a file far too tall for any size is long before it's drawn. Anything
152
+ // else is measured, and once it's too tall it stays long: Paseo remounting the
153
+ // content when the dialog stops scrolling, an image that fails and shrinks, or a
154
+ // diagram that grows can't flip it back. Kept in the query cache to survive the remount.
155
+ const longKey = ["progress", "preview-long", workspaceId, attachment.ref, attachment.path];
156
+ const latched = useQuery({ queryKey: longKey, queryFn: () => false, enabled: false }).data === true;
157
+ const data = preview.data;
158
+ const markdown = data?.kind === "markdown" ? data.markdown : null;
159
+ const floor = useMemo(() => (markdown === null ? 0 : markdownFloor(markdown)), [markdown]);
160
+ const certainlyLong = floor > 2 * room;
161
+ const lastLong = useRef(false);
149
162
  // A long file gets its own scroll area so the actions below it stay in view.
150
163
  // Short ones keep the dialog sized to them.
151
- const long = estimatedHeight(preview.data, aspectRatio, maxMediaHeight) > room;
164
+ const long = data?.kind === "markdown" ? (stepping ? lastLong.current : certainlyLong || latched) : estimatedHeight(data, aspectRatio, maxMediaHeight) > room;
165
+ lastLong.current = long;
152
166
  useEffect(() => onLong(long), [long]);
167
+ const measure = !long && !stepping ? (event: LayoutChangeEvent) => {
168
+ if (event.nativeEvent.layout.height > room) queryClient.setQueryData(longKey, true);
169
+ } : undefined;
153
170
  // How far a video has loaded: under the spinner, or in place of the path while
154
171
  // the last file stays on screen, so nothing moves.
155
172
  const videoLoading = preview.isFetching && loading !== undefined && loading < 1 ? m.loadingVideo(Math.floor(loading * 100)) : null;
156
173
  const caption = { color: colors.foregroundMuted, fontSize: 12, lineHeight: 17 } as const;
174
+ const loadingState = (
175
+ <View style={{ alignItems: "center", gap: 8, padding: 24 }}>
176
+ <ActivityIndicator color={colors.foregroundMuted} accessibilityLabel={m.loadingPreview} />
177
+ {videoLoading ? <Text style={[caption, { fontVariant: ["tabular-nums"] }]}>{videoLoading}</Text> : null}
178
+ </View>
179
+ );
180
+ const truncatedNote = (paddingTop: number) => <Text style={{ color: colors.foregroundMuted, fontSize: 11, lineHeight: 16, paddingTop }}>{m.truncatedText}{canOpen ? m.openRest : ""}</Text>;
157
181
  const content = (
158
182
  <>
159
- {preview.isPending ? (
160
- <View style={{ alignItems: "center", gap: 8, padding: 24 }}>
161
- <ActivityIndicator color={colors.foregroundMuted} accessibilityLabel={m.loadingPreview} />
162
- {videoLoading ? <Text style={[caption, { fontVariant: ["tabular-nums"] }]}>{videoLoading}</Text> : null}
163
- </View>
164
- ) : null}
183
+ {preview.isPending ? loadingState : null}
165
184
  {preview.error ? (
166
185
  <Text accessibilityRole="alert" selectable style={{ color: colors.statusDanger, fontSize: 12, lineHeight: 18 }}>
167
186
  {m.previewError(preview.error instanceof VideoChangedError ? m.videoChanged : errorText(preview.error, m.unknownError))}
@@ -172,13 +191,16 @@ export function PreviewContent({ colors, attachment, workspaceId, workspaceDirec
172
191
  <Text selectable style={{ color: colors.foreground, fontFamily: Platform.OS === "ios" ? "Menlo" : "monospace", fontSize: 12, lineHeight: 18 }}>
173
192
  {preview.data.text}
174
193
  </Text>
175
- {preview.data.truncated ? (
176
- <Text style={{ color: colors.foregroundMuted, fontSize: 11, lineHeight: 16, paddingTop: 8 }}>
177
- {m.truncatedText}{canOpen ? m.openRest : ""}
178
- </Text>
179
- ) : null}
194
+ {preview.data.truncated ? truncatedNote(8) : null}
180
195
  </View>
181
196
  ) : null}
197
+ {/* Straight on the dialog: Paseo's code blocks and tables bring their own fills. */}
198
+ {preview.data?.kind === "markdown" ? (certainlyLong && !ownScroll ? loadingState : (
199
+ <View onLayout={measure} style={{ gap: 12, opacity: stepping ? 0.6 : 1 }}>
200
+ <Markdown>{preview.data.markdown}</Markdown>
201
+ {preview.data.truncated ? truncatedNote(0) : null}
202
+ </View>
203
+ )) : null}
182
204
  {preview.data?.kind === "folder" ? (
183
205
  <FolderList colors={colors} folder={attachment} entries={folderAttachments(attachment, preview.data.entries)} truncated={preview.data.truncated}
184
206
  dimmed={stepping} onOpen={onOpenEntry} />
@@ -364,6 +386,17 @@ function estimatedHeight(data: { kind: "image" | "video" | "html" } | { kind: "t
364
386
  return lines * 18 + 20;
365
387
  }
366
388
 
389
+ // Paseo's smallest text sizes draw no line under 13px: 9px code at 1.45, 10px
390
+ // prose at 1.4. Paseo draws every line break in Markdown, so each non-blank line
391
+ // takes at least one line.
392
+ const MIN_MARKDOWN_LINE = 12;
393
+
394
+ export function markdownFloor(markdown: string): number {
395
+ let lines = 0;
396
+ for (const line of markdown.split("\n")) if (line.trim()) lines++;
397
+ return lines * MIN_MARKDOWN_LINE;
398
+ }
399
+
367
400
  function FolderList({ colors, folder, entries, truncated, dimmed, onOpen }: { colors: Colors; folder: Attachment; entries: Attachment[]; truncated: boolean; dimmed: boolean; onOpen(attachment: Attachment, group: Attachment[]): void }) {
368
401
  const { messages: m } = useProgressLanguage();
369
402
  const caption = { color: colors.foregroundMuted, fontSize: 12, lineHeight: 17 } as const;
@@ -388,7 +421,7 @@ function imageSize(uri: string): Promise<{ width: number | null; ratio: number |
388
421
  // Height the dialog has for the preview before it must scroll: Paseo caps the card
389
422
  // at 85% of the window (inside a 24px margin), less its header and padding, the
390
423
  // path caption (17px after an 8px gap) and the footer (30px after a 20px gap).
391
- function roomForContent(windowHeight: number): number {
424
+ export function roomForContent(windowHeight: number): number {
392
425
  return (windowHeight - 48) * 0.85 - 57 - 48 - (8 + 17) - (20 + 30);
393
426
  }
394
427
 
@@ -1,5 +1,5 @@
1
1
  import type { PluginWorkspacePanelProps } from "@getpaseo/plugin/client";
2
- import { copyText, Icon, useToast } from "@getpaseo/plugin/client/react-native";
2
+ import { copyText, Icon, useToast } from "@getpaseo/plugin/client/ui";
3
3
  import React, { useState } from "react";
4
4
  import { Text, View } from "react-native";
5
5
  import type { Question } from "../shared/dashboard";
@@ -1,5 +1,5 @@
1
1
  import type { PluginWorkspacePanelProps } from "@getpaseo/plugin/client";
2
- import { Icon } from "@getpaseo/plugin/client/react-native";
2
+ import { Icon } from "@getpaseo/plugin/client/ui";
3
3
  import React from "react";
4
4
  import { Text, View, type ViewStyle } from "react-native";
5
5
 
@@ -1,5 +1,5 @@
1
1
  import { type PluginSurfaceProps, useRpc, useSettings } from "@getpaseo/plugin/client";
2
- import { Icon, Modal } from "@getpaseo/plugin/client/react-native";
2
+ import { Icon, Modal } from "@getpaseo/plugin/client/ui";
3
3
  import { SettingsAction, SettingsCard, SettingsSelect } from "@getpaseo/plugin/client/ui";
4
4
  import { useQuery } from "@tanstack/react-query";
5
5
  import React, { type ReactNode, useState } from "react";
@@ -7,11 +7,11 @@ import { Text, View } from "react-native";
7
7
  import { DEFAULT_QUESTION_MODE, QUESTION_MODES } from "../shared/question-modes";
8
8
  import { readSkill } from "../shared/rpc";
9
9
  import { questionSettings, LANGUAGE_PREFERENCES } from "../shared/settings";
10
- import { parseWriteUp, withoutTitle } from "../shared/write-up";
10
+ import { parseBlocks, withoutTitle } from "../shared/blocks";
11
11
  import { errorText } from "./errors";
12
12
  import { useProgressLanguage } from "./language";
13
13
  import { useSkill } from "./launcher";
14
- import { BlockView } from "./write-up";
14
+ import { BlockView } from "./blocks";
15
15
 
16
16
  export function QuestionSettings({ theme, host }: PluginSurfaceProps) {
17
17
  return <><QuestionModeSettings theme={theme} /><DisplaySettings theme={theme} /><SkillSettings colors={theme.colors} hostId={host.id} /></>;
@@ -53,7 +53,7 @@ function SkillSettings({ colors, hostId }: { colors: Colors; hostId: string }) {
53
53
  const fetchSkill = useRpc(readSkill);
54
54
  const document = useQuery({ queryKey: ["progress", "skill-document", hostId], queryFn: () => fetchSkill({}), staleTime: 60_000 });
55
55
  const [open, setOpen] = useState(false);
56
- const blocks = document.data ? withoutTitle(parseWriteUp(document.data.markdown)) : [];
56
+ const blocks = document.data ? withoutTitle(parseBlocks(document.data.markdown)) : [];
57
57
  const muted = { color: colors.foregroundMuted, fontSize: 13, lineHeight: 19 };
58
58
  return (
59
59
  <Section colors={colors} icon="Sparkles" title={m.agentSkill}>
@@ -1,5 +1,5 @@
1
1
  import type { PluginWorkspacePanelProps } from "@getpaseo/plugin/client";
2
- import { Icon } from "@getpaseo/plugin/client/react-native";
2
+ import { Icon } from "@getpaseo/plugin/client/ui";
3
3
  import React, { useState } from "react";
4
4
  import { Text } from "react-native";
5
5
  import { IconSwap } from "./motion";
@@ -1,4 +1,4 @@
1
- import { Icon } from "@getpaseo/plugin/client/react-native";
1
+ import { Icon } from "@getpaseo/plugin/client/ui";
2
2
  import React, { useEffect, useRef } from "react";
3
3
  import { ActivityIndicator, Animated, Easing, Platform, View } from "react-native";
4
4
  import { useProgressLanguage } from "./language";
package/client/tabs.tsx CHANGED
@@ -1,5 +1,5 @@
1
1
  import type { PluginWorkspacePanelProps } from "@getpaseo/plugin/client";
2
- import { Icon } from "@getpaseo/plugin/client/react-native";
2
+ import { Icon } from "@getpaseo/plugin/client/ui";
3
3
  import React, { useState } from "react";
4
4
  import { Pressable, Text, View } from "react-native";
5
5
 
@@ -1,5 +1,5 @@
1
1
  import { type PluginWorkspacePanelProps, useAgent } from "@getpaseo/plugin/client";
2
- import { Icon } from "@getpaseo/plugin/client/react-native";
2
+ import { Icon } from "@getpaseo/plugin/client/ui";
3
3
  import React, { useState } from "react";
4
4
  import { Text, View } from "react-native";
5
5
  import type { Dashboard, Ticket } from "../shared/dashboard";
@@ -1,14 +1,14 @@
1
1
  import { type PluginWorkspacePanelProps, useRpc } from "@getpaseo/plugin/client";
2
- import { Icon, Modal } from "@getpaseo/plugin/client/react-native";
2
+ import { Icon, Modal } from "@getpaseo/plugin/client/ui";
3
3
  import { useQuery, useQueryClient } from "@tanstack/react-query";
4
4
  import React, { useState } from "react";
5
5
  import { Text, View } from "react-native";
6
6
  import { dismissWhatsNew, getWhatsNew } from "../shared/rpc";
7
7
  import { NOTE_KINDS, type NoteKind, type NoteTone, type WhatsNew, type WhatsNewNote, type WhatsNewRelease } from "../shared/whats-new";
8
- import { spans } from "../shared/write-up";
8
+ import { spans } from "../shared/blocks";
9
9
  import { PressScale, Presence } from "./motion";
10
10
  import { isLight, raised, tint } from "./surfaces";
11
- import { BlockView, Spans } from "./write-up";
11
+ import { BlockView, Spans } from "./blocks";
12
12
  import { ProgressLanguageProvider, useProgressLanguage } from "./language";
13
13
 
14
14
  type Colors = PluginWorkspacePanelProps["theme"]["colors"];
@@ -1,18 +1,20 @@
1
1
  import { type PluginWorkspacePanelProps, useRpc } from "@getpaseo/plugin/client";
2
- import { Icon } from "@getpaseo/plugin/client/react-native";
2
+ import { Icon, Markdown } from "@getpaseo/plugin/client/ui";
3
3
  import { useQuery } from "@tanstack/react-query";
4
- import React, { useState } from "react";
4
+ import React, { useMemo, useState } from "react";
5
5
  import { Platform, Text, View } from "react-native";
6
6
  import { previewKind } from "../shared/attachments";
7
7
  import { previewDeliverable } from "../shared/rpc";
8
- import { type Block, parseWriteUp, type Span, withoutTitle, writeUpExcerpt } from "../shared/write-up";
8
+ import { flattenHeadings, dropTitle, markdownExcerpt, textExcerpt } from "../shared/markdown";
9
9
  import { type Attachment, attachmentIcon } from "./attachments";
10
10
  import { PressableRow } from "./row";
11
11
  import { errorText } from "./errors";
12
12
  import { useProgressLanguage } from "./language";
13
13
 
14
14
  type Colors = PluginWorkspacePanelProps["theme"]["colors"];
15
- const MONO = Platform.OS === "ios" ? "Menlo" : "monospace";
15
+
16
+ const WRITE_UP_HEADING = 5;
17
+ const EXCERPT_CHARS = 280;
16
18
 
17
19
  // What the ticket is: the start of its write-up in a quiet inset, with the
18
20
  // rest a press away. A link or a file that isn't text shows only its name.
@@ -34,18 +36,31 @@ export function WriteUp({ colors, source, workspaceId, workspaceDirectory, onOpe
34
36
  enabled: readable,
35
37
  });
36
38
  const [all, setAll] = useState(false);
37
- const blocks = preview.data?.kind === "text" ? parseWriteUp(preview.data.text) : [];
38
- const excerpt = writeUpExcerpt(blocks, 280);
39
- const shown = all ? withoutTitle(blocks) : excerpt.blocks;
39
+ const data = preview.data;
40
+ const parts = useMemo(() => {
41
+ if (data?.kind === "markdown") {
42
+ const excerpt = markdownExcerpt(data.markdown, EXCERPT_CHARS);
43
+ return { markdown: true, excerpt: flattenHeadings(excerpt.text, WRITE_UP_HEADING), whole: flattenHeadings(dropTitle(data.markdown), WRITE_UP_HEADING), more: excerpt.more, truncated: data.truncated };
44
+ }
45
+ if (data?.kind === "text") {
46
+ const excerpt = textExcerpt(data.text, EXCERPT_CHARS);
47
+ return { markdown: false, excerpt: excerpt.text, whole: data.text, more: excerpt.more, truncated: data.truncated };
48
+ }
49
+ return null;
50
+ }, [data]);
51
+ const shown = parts ? (all ? parts.whole : parts.excerpt) : "";
52
+ const muted = { color: colors.foregroundMuted, fontSize: 12, lineHeight: 18 } as const;
40
53
  return (
41
54
  <View style={{ gap: 12, padding: 14, borderRadius: 8, backgroundColor: colors.surface0 }}>
42
- {shown.length ? <View style={{ gap: 8 }}>{shown.map((block, index) => <BlockView key={index} colors={colors} block={block} first={index === 0} />)}</View> : null}
43
- {preview.error ? <Text style={{ color: colors.foregroundMuted, fontSize: 12, lineHeight: 18 }}>{m.readError(errorText(preview.error, m.unknownError))}</Text> : null}
55
+ {shown.trim() ? (parts?.markdown ? <Markdown>{shown}</Markdown>
56
+ : <Text selectable style={{ color: colors.foreground, fontSize: 13, lineHeight: 19 }}>{shown}</Text>) : null}
57
+ {all && parts?.truncated ? <Text style={muted}>{m.truncatedText}</Text> : null}
58
+ {preview.error ? <Text style={muted}>{m.readError(errorText(preview.error, m.unknownError))}</Text> : null}
44
59
  {/* The file on the left, opening it whole; expanding in place on the right. */}
45
60
  <View style={{ flexDirection: "row", alignItems: "center", gap: 12 }}>
46
61
  <TextButton colors={colors} icon={attachmentIcon(source)} label={source.title} accessibilityLabel={m.openItem(source.title)} onPress={() => onOpen(source, [source])} grow />
47
62
  <View style={{ flex: 1 }} />
48
- {excerpt.more ? <TextButton colors={colors} label={all ? m.showLess : m.readMore} onPress={() => setAll(!all)} expanded={all} /> : null}
63
+ {parts?.more ? <TextButton colors={colors} label={all ? m.showLess : m.readMore} onPress={() => setAll(!all)} expanded={all} /> : null}
49
64
  </View>
50
65
  </View>
51
66
  );
@@ -63,37 +78,3 @@ function TextButton({ colors, icon, label, accessibilityLabel, onPress, grow = f
63
78
  </PressableRow>
64
79
  );
65
80
  }
66
-
67
- export function BlockView({ colors, block, first }: { colors: Colors; block: Block; first: boolean }) {
68
- switch (block.kind) {
69
- case "heading":
70
- return (
71
- // A step under the section's own 13px title: smaller and muted, set apart from the body by weight.
72
- <Text accessibilityRole="header" selectable style={{ color: colors.foregroundMuted, fontSize: 12, lineHeight: 17, fontWeight: "600", marginTop: first ? 0 : 6 }}>
73
- <Spans colors={colors} spans={block.spans} />
74
- </Text>
75
- );
76
- case "item":
77
- return (
78
- <View style={{ flexDirection: "row", gap: 6, paddingStart: block.depth * 14 }}>
79
- <Text style={{ minWidth: 10, color: colors.foregroundMuted, fontSize: 13, lineHeight: 19, fontVariant: ["tabular-nums"] }}>{block.marker}</Text>
80
- <Text selectable style={{ flex: 1, color: colors.foreground, fontSize: 13, lineHeight: 19 }}><Spans colors={colors} spans={block.spans} /></Text>
81
- </View>
82
- );
83
- case "code":
84
- return (
85
- <View style={{ padding: 8, borderRadius: 6, backgroundColor: colors.surface0 }}>
86
- <Text selectable style={{ color: colors.foreground, fontFamily: MONO, fontSize: 12, lineHeight: 18 }}>{block.text}</Text>
87
- </View>
88
- );
89
- default:
90
- return <Text selectable style={{ color: colors.foreground, fontSize: 13, lineHeight: 19 }}><Spans colors={colors} spans={block.spans} /></Text>;
91
- }
92
- }
93
-
94
- export function Spans({ colors, spans }: { colors: Colors; spans: Span[] }) {
95
- return <>{spans.map((span, index) => span.code
96
- ? <Text key={index} style={{ fontFamily: MONO, fontSize: 12, color: colors.foreground }}>{span.text}</Text>
97
- : span.strong ? <Text key={index} style={{ fontWeight: "600" }}>{span.text}</Text>
98
- : <React.Fragment key={index}>{span.text}</React.Fragment>)}</>;
99
- }
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(client);
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.11.0",
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": "^0.11.2",
54
- "@getpaseo/plugin": "^0.11.2",
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
@@ -9,6 +9,6 @@
9
9
  "images/5-settings.png"
10
10
  ],
11
11
  "requirements": {
12
- "paseo": ">=0.11.0"
12
+ "paseo": ">=0.13.0"
13
13
  }
14
14
  }