@stevecastaneda/paseo-progress 1.5.0 → 1.6.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/README.md +14 -3
- package/client/launcher.tsx +10 -5
- package/client/locales/en.ts +7 -0
- package/client/locales/zh-CN.ts +7 -0
- package/client/messages.ts +7 -0
- package/client/settings.tsx +59 -10
- package/client/whats-new.tsx +22 -6
- package/images/5-settings.png +0 -0
- package/index.server.ts +4 -2
- package/package.json +1 -1
- package/server/skill.ts +55 -12
- package/server/whats-new.ts +44 -23
- package/shared/rpc.ts +8 -0
- package/shared/whats-new.ts +4 -1
- package/shared/write-up.ts +7 -5
package/README.md
CHANGED
|
@@ -18,9 +18,9 @@ The dashboard shows:
|
|
|
18
18
|
|
|
19
19
|
<img src="images/2-ticket.png" alt="A ticket's story: its status, time against the estimate, the agent on it, and its timeline" width="400"> <img src="images/3-question.png" alt="A question waiting for an answer, with lettered options, the agent's recommendation and Copy buttons" width="400">
|
|
20
20
|
|
|
21
|
-
<img src="images/5-settings.png" alt="
|
|
21
|
+
<img src="images/5-settings.png" alt="Progress settings in Paseo: Questions set to Work around, Language set to System, and the Agent Skills section with View skill" width="600">
|
|
22
22
|
|
|
23
|
-
The panel lives in Explorer, beside the agent chat, so you can watch both. Open it from Command Center with **Open Progress**. The cog at the top right of the panel opens the plugin's Settings. After an update, the panel shows what's new
|
|
23
|
+
The panel lives in Explorer, beside the agent chat, so you can watch both. Open it from Command Center with **Open Progress**. The cog at the top right of the panel opens the plugin's Settings. After an update, the panel shows what's new once, including any versions you skipped. When questions are waiting or something is stuck, a pill above the message box says so, for example `3 questions`. With something stuck too, it shortens to `3Q · 1 stuck` to fit. Pressing it opens a popover with each open question, its choices and their Copy buttons, and what is stuck, so you can answer without leaving the chat. **Open Progress** at the bottom opens the panel in Explorer.
|
|
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
|
|
|
@@ -82,7 +82,7 @@ Earlier versions kept progress in `.scratch/` in each worktree, hidden by a `.sc
|
|
|
82
82
|
|
|
83
83
|
## Agent skill
|
|
84
84
|
|
|
85
|
-
The plugin ships an agent skill, `paseo-progress`, that tells agents when to run each command during a multi-ticket job: set up the run, move tickets through their stages, ask questions, and finish. It stays short and leaves the details to `paseo-progress --help`. Press **Install skill** in the Progress panel. It links `paseo-progress` into `~/.agents/skills`, `~/.claude/skills`, and `~/.codex/skills` (the folders Paseo installs its own skills into), pointing at the copy of the plugin Paseo runs, so plugin updates reach agents. On Windows the links are junctions, which don't need admin rights or Developer Mode.
|
|
85
|
+
The plugin ships an agent skill, `paseo-progress`, that tells agents when to run each command during a multi-ticket job: set up the run, move tickets through their stages, ask questions, and finish. It stays short and leaves the details to `paseo-progress --help`. Press **Install skill** in the Progress panel. It links `paseo-progress` into `~/.agents/skills`, `~/.claude/skills`, and `~/.codex/skills` (the folders Paseo installs its own skills into), pointing at the copy of the plugin Paseo runs, so plugin updates reach agents. On Windows the links are junctions, which don't need admin rights or Developer Mode. Paseo puts each plugin update in a new folder, so when Progress starts it repoints the links it made to that folder. If it can't, the button changes to **Update skill**. It never replaces a skill it didn't link; that folder is skipped. To read what agents get, open **View skill** under Agent Skills in Progress settings.
|
|
86
86
|
|
|
87
87
|
From a terminal instead:
|
|
88
88
|
|
|
@@ -145,6 +145,17 @@ The progress file, `progress.jsonl`, holds one JSON event per line, each with a
|
|
|
145
145
|
|
|
146
146
|
## Changelog
|
|
147
147
|
|
|
148
|
+
### 1.6.0
|
|
149
|
+
|
|
150
|
+
- [New] **Read the skill in Settings.** Progress settings has an Agent Skills section. **View skill** shows the instructions agents get, and **Install skill** appears there when the skill isn't installed.
|
|
151
|
+
- [Fixed] **The skill stays linked after updates.** Paseo puts each plugin update in a new folder, so after every update Progress asked you to press **Update skill**, even when the skill hadn't changed. Progress now fixes the link itself.
|
|
152
|
+
- [Improved] **What's new catches you up.** If you skip an update, What's new now shows the changes from every version you missed, newest first.
|
|
153
|
+
- [Fixed] **Code inside lists.** A code block inside a list item in a write-up no longer shows shifted to the right.
|
|
154
|
+
|
|
155
|
+
### 1.5.1
|
|
156
|
+
|
|
157
|
+
- [Improved] **Settings screenshot.** It shows the new Language setting.
|
|
158
|
+
|
|
148
159
|
### 1.5.0
|
|
149
160
|
|
|
150
161
|
- [New] **Simplified Chinese.** Progress now comes in English and Simplified Chinese. Pick one under Display in Progress settings, or let it follow each device's language. Thanks to @xzthemeofsss for the translation.
|
package/client/launcher.tsx
CHANGED
|
@@ -70,23 +70,28 @@ export function LauncherBanner({ colors, host }: { colors: Colors; host: PluginW
|
|
|
70
70
|
return <SetupBanner colors={colors} icon="Terminal" notice={notice} pending={mutation.isPending} onPress={() => mutation.mutate()} />;
|
|
71
71
|
}
|
|
72
72
|
|
|
73
|
-
|
|
73
|
+
// The panel banner and Settings share the status and the install action.
|
|
74
|
+
export function useSkill(hostId: string) {
|
|
74
75
|
const { messages: m } = useProgressLanguage();
|
|
75
76
|
const fetchStatus = useRpc(getSkillStatus);
|
|
76
77
|
const install = useRpc(installSkill);
|
|
77
78
|
const queryClient = useQueryClient();
|
|
78
79
|
const toast = useToast();
|
|
79
|
-
const status = useQuery({ queryKey: skillQueryKey(
|
|
80
|
+
const status = useQuery({ queryKey: skillQueryKey(hostId), queryFn: () => fetchStatus({}), staleTime: 60_000 });
|
|
80
81
|
const mutation = useMutation({
|
|
81
82
|
mutationFn: () => install({}),
|
|
82
83
|
onSuccess: (next) => {
|
|
83
|
-
queryClient.setQueryData(skillQueryKey(
|
|
84
|
+
queryClient.setQueryData(skillQueryKey(hostId), next);
|
|
84
85
|
toast.show(m.skillInstalled, { variant: "success" });
|
|
85
86
|
},
|
|
86
87
|
onError: (error) => toast.error(errorText(error, m.installSkillFailure)),
|
|
87
88
|
});
|
|
88
|
-
|
|
89
|
-
|
|
89
|
+
return { notice: status.data ? skillNotice(status.data, m) : null, state: status.data?.state, pending: mutation.isPending, install: () => mutation.mutate() };
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
export function SkillBanner({ colors, host }: { colors: Colors; host: PluginWorkspacePanelProps["host"] }) {
|
|
93
|
+
const skill = useSkill(host.id);
|
|
94
|
+
return <SetupBanner colors={colors} icon="Sparkles" notice={skill.notice} pending={skill.pending} onPress={skill.install} />;
|
|
90
95
|
}
|
|
91
96
|
|
|
92
97
|
function SetupBanner({ colors, icon, notice, pending, onPress }: {
|
package/client/locales/en.ts
CHANGED
|
@@ -86,6 +86,7 @@ export const messages: ProgressMessages = {
|
|
|
86
86
|
showFewer: "Show fewer",
|
|
87
87
|
seeWhatsNew: "See what’s new",
|
|
88
88
|
dismissWhatsNew: "Dismiss what's new",
|
|
89
|
+
whatsNewAll: "What’s new in Progress",
|
|
89
90
|
gotIt: "Got it",
|
|
90
91
|
loadingPreview: "Loading preview",
|
|
91
92
|
truncatedText: "Showing the first 256 KB.",
|
|
@@ -124,6 +125,11 @@ export const messages: ProgressMessages = {
|
|
|
124
125
|
skillForeign: "Something else already exists at every paseo-progress skill path, so the plugin won't install the skill.",
|
|
125
126
|
skillInstalled: "Installed the paseo-progress skill. New agent sessions will use it.",
|
|
126
127
|
installSkillFailure: "Could not install the skill.",
|
|
128
|
+
agentSkill: "Agent Skills",
|
|
129
|
+
skillName: "paseo-progress skill",
|
|
130
|
+
viewSkill: "View skill",
|
|
131
|
+
skillNotInstalled: "Not installed",
|
|
132
|
+
skillLinkOutdated: "Link out of date",
|
|
127
133
|
installing: "Installing…",
|
|
128
134
|
loadingSettings: "Loading settings…",
|
|
129
135
|
tryAgain: "Try again",
|
|
@@ -178,6 +184,7 @@ export const messages: ProgressMessages = {
|
|
|
178
184
|
readError: (error: string) => `Couldn't read it here: ${error}`,
|
|
179
185
|
showOlder: (count: number) => `Show ${count} older`,
|
|
180
186
|
newVersion: (version: string) => `New in ${version}:`,
|
|
187
|
+
updatedTo: (version: string) => `Updated to ${version}:`,
|
|
181
188
|
whatsNewTitle: (version: string) => `What’s new in Progress ${version}`,
|
|
182
189
|
loadingVideo: (percent: number) => `Loading video, ${percent}%`,
|
|
183
190
|
previewError: (error: string) => `Could not preview it: ${error}`,
|
package/client/locales/zh-CN.ts
CHANGED
|
@@ -86,6 +86,7 @@ export const messages: ProgressMessages = {
|
|
|
86
86
|
showFewer: "收起",
|
|
87
87
|
seeWhatsNew: "查看更新",
|
|
88
88
|
dismissWhatsNew: "关闭更新提示",
|
|
89
|
+
whatsNewAll: "Progress 更新内容",
|
|
89
90
|
gotIt: "知道了",
|
|
90
91
|
loadingPreview: "正在加载预览",
|
|
91
92
|
truncatedText: "仅显示前 256 KB。",
|
|
@@ -124,6 +125,11 @@ export const messages: ProgressMessages = {
|
|
|
124
125
|
skillForeign: "所有 paseo-progress 技能路径都已被其他内容占用,插件无法安装技能。",
|
|
125
126
|
skillInstalled: "已安装 paseo-progress 技能。新的智能体会话将使用此技能。",
|
|
126
127
|
installSkillFailure: "无法安装技能。",
|
|
128
|
+
agentSkill: "智能体技能",
|
|
129
|
+
skillName: "paseo-progress 技能",
|
|
130
|
+
viewSkill: "查看技能",
|
|
131
|
+
skillNotInstalled: "未安装",
|
|
132
|
+
skillLinkOutdated: "链接已过期",
|
|
127
133
|
installing: "安装中…",
|
|
128
134
|
loadingSettings: "正在加载设置…",
|
|
129
135
|
tryAgain: "重试",
|
|
@@ -178,6 +184,7 @@ export const messages: ProgressMessages = {
|
|
|
178
184
|
readError: (error: string) => `无法在此读取:${error}`,
|
|
179
185
|
showOlder: (count: number) => `显示更早的 ${count} 项`,
|
|
180
186
|
newVersion: (version: string) => `${version} 的更新:`,
|
|
187
|
+
updatedTo: (version: string) => `已更新到 ${version}:`,
|
|
181
188
|
whatsNewTitle: (version: string) => `Progress ${version} 更新内容`,
|
|
182
189
|
loadingVideo: (percent: number) => `正在加载视频,${percent}%`,
|
|
183
190
|
previewError: (error: string) => `无法预览:${error}`,
|
package/client/messages.ts
CHANGED
|
@@ -68,6 +68,7 @@ export interface ProgressMessages {
|
|
|
68
68
|
showFewer: string;
|
|
69
69
|
seeWhatsNew: string;
|
|
70
70
|
dismissWhatsNew: string;
|
|
71
|
+
whatsNewAll: string;
|
|
71
72
|
gotIt: string;
|
|
72
73
|
loadingPreview: string;
|
|
73
74
|
truncatedText: string;
|
|
@@ -106,6 +107,11 @@ export interface ProgressMessages {
|
|
|
106
107
|
skillForeign: string;
|
|
107
108
|
skillInstalled: string;
|
|
108
109
|
installSkillFailure: string;
|
|
110
|
+
agentSkill: string;
|
|
111
|
+
skillName: string;
|
|
112
|
+
viewSkill: string;
|
|
113
|
+
skillNotInstalled: string;
|
|
114
|
+
skillLinkOutdated: string;
|
|
109
115
|
installing: string;
|
|
110
116
|
loadingSettings: string;
|
|
111
117
|
tryAgain: string;
|
|
@@ -160,6 +166,7 @@ export interface ProgressMessages {
|
|
|
160
166
|
readError: (error: string) => string;
|
|
161
167
|
showOlder: (count: number) => string;
|
|
162
168
|
newVersion: (version: string) => string;
|
|
169
|
+
updatedTo: (version: string) => string;
|
|
163
170
|
whatsNewTitle: (version: string) => string;
|
|
164
171
|
loadingVideo: (percent: number) => string;
|
|
165
172
|
previewError: (error: string) => string;
|
package/client/settings.tsx
CHANGED
|
@@ -1,29 +1,78 @@
|
|
|
1
|
-
import { type PluginSurfaceProps, useSettings } from "@getpaseo/plugin/client";
|
|
2
|
-
import {
|
|
3
|
-
import
|
|
4
|
-
import {
|
|
1
|
+
import { type PluginSurfaceProps, useRpc, useSettings } from "@getpaseo/plugin/client";
|
|
2
|
+
import { Icon, Modal } from "@getpaseo/plugin/client/react-native";
|
|
3
|
+
import { SettingsAction, SettingsCard, SettingsSelect } from "@getpaseo/plugin/client/ui";
|
|
4
|
+
import { useQuery } from "@tanstack/react-query";
|
|
5
|
+
import React, { type ReactNode, useState } from "react";
|
|
6
|
+
import { Text, View } from "react-native";
|
|
5
7
|
import { DEFAULT_QUESTION_MODE, QUESTION_MODES } from "../shared/question-modes";
|
|
8
|
+
import { readSkill } from "../shared/rpc";
|
|
6
9
|
import { questionSettings, LANGUAGE_PREFERENCES } from "../shared/settings";
|
|
10
|
+
import { parseWriteUp, withoutTitle } from "../shared/write-up";
|
|
11
|
+
import { errorText } from "./errors";
|
|
7
12
|
import { useProgressLanguage } from "./language";
|
|
13
|
+
import { useSkill } from "./launcher";
|
|
14
|
+
import { BlockView } from "./write-up";
|
|
8
15
|
|
|
9
|
-
export function QuestionSettings({ theme }: PluginSurfaceProps) {
|
|
10
|
-
return <><QuestionModeSettings theme={theme} /><DisplaySettings theme={theme} /></>;
|
|
16
|
+
export function QuestionSettings({ theme, host }: PluginSurfaceProps) {
|
|
17
|
+
return <><QuestionModeSettings theme={theme} /><DisplaySettings theme={theme} /><SkillSettings colors={theme.colors} hostId={host.id} /></>;
|
|
11
18
|
}
|
|
12
19
|
|
|
13
20
|
type Colors = PluginSurfaceProps["theme"]["colors"];
|
|
14
21
|
|
|
22
|
+
// Paseo's SettingsSection has no icon slot, so this copies its layout and adds one.
|
|
23
|
+
function Section({ colors, icon, title, children }: { colors: Colors; icon: string; title: string; children: ReactNode }) {
|
|
24
|
+
return (
|
|
25
|
+
<View style={{ marginBottom: 24 }}>
|
|
26
|
+
<View style={{ flexDirection: "row", alignItems: "center", gap: 8, marginBottom: 12, marginLeft: 4 }}>
|
|
27
|
+
<Icon name={icon} size={14} color={colors.foregroundMuted} />
|
|
28
|
+
<Text style={{ color: colors.foregroundMuted, fontSize: 12, fontWeight: "400" }}>{title}</Text>
|
|
29
|
+
</View>
|
|
30
|
+
<View style={{ gap: 12 }}>{children}</View>
|
|
31
|
+
</View>
|
|
32
|
+
);
|
|
33
|
+
}
|
|
34
|
+
|
|
15
35
|
function DisplaySettings({ theme }: Pick<PluginSurfaceProps, "theme">) {
|
|
16
36
|
const { messages: m, settings } = useProgressLanguage();
|
|
17
37
|
const muted = { color: theme.colors.foregroundMuted, fontSize: 13, lineHeight: 19 };
|
|
18
38
|
const options = LANGUAGE_PREFERENCES.map((value) => ({ value, label: value === "system" ? m.systemLanguage : value === "en" ? "English" : "简体中文" }));
|
|
19
39
|
return (
|
|
20
|
-
<
|
|
40
|
+
<Section colors={theme.colors} icon="Languages" title={m.display}>
|
|
21
41
|
{settings.status === "loading" ? <Text style={muted}>{m.loadingSettings}</Text>
|
|
22
42
|
: settings.status !== "ready" ? <SettingsRecovery colors={theme.colors} settings={settings} />
|
|
23
43
|
: <SettingsCard><SettingsSelect label={m.language} hint={m.languageHint} value={settings.values.language} options={options}
|
|
24
44
|
disabled={settings.saving} error={settings.saveError}
|
|
25
45
|
onValueChange={(language) => void settings.save({ ...settings.values, language }, settings.revision)} /></SettingsCard>}
|
|
26
|
-
</
|
|
46
|
+
</Section>
|
|
47
|
+
);
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function SkillSettings({ colors, hostId }: { colors: Colors; hostId: string }) {
|
|
51
|
+
const { messages: m } = useProgressLanguage();
|
|
52
|
+
const skill = useSkill(hostId);
|
|
53
|
+
const fetchSkill = useRpc(readSkill);
|
|
54
|
+
const document = useQuery({ queryKey: ["progress", "skill-document", hostId], queryFn: () => fetchSkill({}), staleTime: 60_000 });
|
|
55
|
+
const [open, setOpen] = useState(false);
|
|
56
|
+
const blocks = document.data ? withoutTitle(parseWriteUp(document.data.markdown)) : [];
|
|
57
|
+
const muted = { color: colors.foregroundMuted, fontSize: 13, lineHeight: 19 };
|
|
58
|
+
return (
|
|
59
|
+
<Section colors={colors} icon="Sparkles" title={m.agentSkill}>
|
|
60
|
+
<SettingsCard>
|
|
61
|
+
<SettingsAction label={m.skillName} hint={document.data?.description || undefined} actionLabel={m.viewSkill} onPress={() => setOpen(true)} />
|
|
62
|
+
{skill.notice?.action ? (
|
|
63
|
+
<SettingsAction label={skill.state === "outdated" ? m.skillLinkOutdated : m.skillNotInstalled} hint={skill.notice.text}
|
|
64
|
+
actionLabel={skill.pending ? m.installing : skill.notice.action} disabled={skill.pending} onPress={skill.install} />
|
|
65
|
+
) : null}
|
|
66
|
+
</SettingsCard>
|
|
67
|
+
{/* Paseo draws the modal outside this tree, past the language context, so it only gets finished text. */}
|
|
68
|
+
<Modal title={m.skillName} icon={<Icon name="Sparkles" size={16} color={colors.foregroundMuted} />} open={open} onOpenChange={setOpen}>
|
|
69
|
+
<Modal.Content>
|
|
70
|
+
{document.error ? <Text style={muted}>{m.readError(errorText(document.error, m.unknownError))}</Text>
|
|
71
|
+
: !document.data ? <Text style={muted}>{m.loading}</Text>
|
|
72
|
+
: <View style={{ gap: 8 }}>{blocks.map((block, index) => <BlockView key={index} colors={colors} block={block} first={index === 0} />)}</View>}
|
|
73
|
+
</Modal.Content>
|
|
74
|
+
</Modal>
|
|
75
|
+
</Section>
|
|
27
76
|
);
|
|
28
77
|
}
|
|
29
78
|
|
|
@@ -32,13 +81,13 @@ function QuestionModeSettings({ theme }: Pick<PluginSurfaceProps, "theme">) {
|
|
|
32
81
|
const settings = useSettings(questionSettings);
|
|
33
82
|
const muted = { color: theme.colors.foregroundMuted, fontSize: 13, lineHeight: 19 };
|
|
34
83
|
return (
|
|
35
|
-
<
|
|
84
|
+
<Section colors={theme.colors} icon="MessageCircleQuestion" title={m.questions}>
|
|
36
85
|
{settings.status === "loading" ? <Text style={muted}>{m.loadingSettings}</Text>
|
|
37
86
|
: settings.status !== "ready" ? <SettingsRecovery colors={theme.colors} settings={settings} resetLabel={m.resetTo(m.questionModes[DEFAULT_QUESTION_MODE].label)} />
|
|
38
87
|
: <SettingsCard><SettingsSelect label={m.questionMode} hint={m.questionModes[settings.values.mode].hint}
|
|
39
88
|
error={settings.saveError} value={settings.values.mode} options={QUESTION_MODES.map((value) => ({ value, label: m.questionModes[value].label }))}
|
|
40
89
|
disabled={settings.saving} onValueChange={(mode) => void settings.save({ ...settings.values, mode }, settings.revision)} /></SettingsCard>}
|
|
41
|
-
</
|
|
90
|
+
</Section>
|
|
42
91
|
);
|
|
43
92
|
}
|
|
44
93
|
|
package/client/whats-new.tsx
CHANGED
|
@@ -4,7 +4,7 @@ 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
|
-
import { NOTE_KINDS, type NoteKind, type NoteTone, type WhatsNew, type WhatsNewNote } from "../shared/whats-new";
|
|
7
|
+
import { NOTE_KINDS, type NoteKind, type NoteTone, type WhatsNew, type WhatsNewNote, type WhatsNewRelease } from "../shared/whats-new";
|
|
8
8
|
import { spans } from "../shared/write-up";
|
|
9
9
|
import { PressScale, Presence } from "./motion";
|
|
10
10
|
import { isLight, raised, tint } from "./surfaces";
|
|
@@ -13,8 +13,8 @@ import { ProgressLanguageProvider, useProgressLanguage } from "./language";
|
|
|
13
13
|
|
|
14
14
|
type Colors = PluginWorkspacePanelProps["theme"]["colors"];
|
|
15
15
|
|
|
16
|
-
// Once after an update:
|
|
17
|
-
// away. Closing either hides both
|
|
16
|
+
// Once after an update: the headline of what changed, with the full notes a
|
|
17
|
+
// press away. Closing either hides both until the next version.
|
|
18
18
|
export function WhatsNewBanner({ colors, host }: { colors: Colors; host: PluginWorkspacePanelProps["host"] }) {
|
|
19
19
|
const { messages: m } = useProgressLanguage();
|
|
20
20
|
const fetchWhatsNew = useRpc(getWhatsNew);
|
|
@@ -42,7 +42,7 @@ export function WhatsNewBanner({ colors, host }: { colors: Colors; host: PluginW
|
|
|
42
42
|
<View style={{ flexDirection: "row", alignItems: "center", flexWrap: "wrap", gap: 10, margin: 12, marginBottom: 0, padding: 8, borderRadius: 12, ...raised(colors), backgroundColor: colors.surface1 }}>
|
|
43
43
|
<View style={{ alignSelf: "flex-start", height: 17, justifyContent: "center" }}><Icon name="Gift" size={14} color={colors.foregroundMuted} /></View>
|
|
44
44
|
<Text selectable style={{ flex: 1, minWidth: 200, color: colors.foregroundMuted, fontSize: 12, lineHeight: 17 }}>
|
|
45
|
-
<Text style={{ color: colors.foreground, fontWeight: "500" }}>{m.newVersion(whatsNew.version)}</Text> {whatsNew.headline}
|
|
45
|
+
<Text style={{ color: colors.foreground, fontWeight: "500" }}>{whatsNew.releases.length > 1 ? m.updatedTo(whatsNew.version) : m.newVersion(whatsNew.version)}</Text> {whatsNew.headline}
|
|
46
46
|
</Text>
|
|
47
47
|
{/* 8px keeps the close button's 6px hit slop off the button beside it. */}
|
|
48
48
|
<View style={{ flexDirection: "row", alignItems: "center", gap: 8 }}>
|
|
@@ -57,11 +57,11 @@ export function WhatsNewBanner({ colors, host }: { colors: Colors; host: PluginW
|
|
|
57
57
|
</View>
|
|
58
58
|
</View>
|
|
59
59
|
) : null}</Presence>
|
|
60
|
-
<Modal title={shown ? m.whatsNewTitle(shown.version)
|
|
60
|
+
<Modal title={!shown ? "" : shown.releases.length > 1 ? m.whatsNewAll : m.whatsNewTitle(shown.version)} icon={<Icon name="Gift" size={16} color={colors.foregroundMuted} />}
|
|
61
61
|
open={open} onOpenChange={(next) => { if (!next) closeDialog(); }}>
|
|
62
62
|
<Modal.Content>
|
|
63
63
|
<ProgressLanguageProvider>
|
|
64
|
-
{shown ? <
|
|
64
|
+
{shown ? <Releases colors={colors} releases={shown.releases} /> : null}
|
|
65
65
|
<View style={{ flexDirection: "row", justifyContent: "flex-end", marginTop: 8 }}>
|
|
66
66
|
<PressScale accessibilityRole="button" onPress={closeDialog}
|
|
67
67
|
style={({ pressed, hovered }) => ({ paddingHorizontal: 12, paddingVertical: 6, borderRadius: 6, backgroundColor: colors.accent, opacity: pressed ? 0.7 : hovered ? 0.85 : 1 })}>
|
|
@@ -75,6 +75,22 @@ export function WhatsNewBanner({ colors, host }: { colors: Colors; host: PluginW
|
|
|
75
75
|
);
|
|
76
76
|
}
|
|
77
77
|
|
|
78
|
+
// One version reads as before. After a skipped update, each version's notes sit
|
|
79
|
+
// under its number, so it's clear which release brought what.
|
|
80
|
+
function Releases({ colors, releases }: { colors: Colors; releases: WhatsNewRelease[] }) {
|
|
81
|
+
if (releases.length === 1) return <Notes colors={colors} notes={releases[0].notes} />;
|
|
82
|
+
return (
|
|
83
|
+
<View style={{ gap: 40 }}>
|
|
84
|
+
{releases.map((release) => (
|
|
85
|
+
<View key={release.version} style={{ gap: 12 }}>
|
|
86
|
+
<Text accessibilityRole="header" selectable style={{ color: colors.foreground, fontSize: 12, lineHeight: 16, fontWeight: "600" }}>{release.version}</Text>
|
|
87
|
+
<Notes colors={colors} notes={release.notes} />
|
|
88
|
+
</View>
|
|
89
|
+
))}
|
|
90
|
+
</View>
|
|
91
|
+
);
|
|
92
|
+
}
|
|
93
|
+
|
|
78
94
|
// Titled notes read as a short list of headings with their details; a changelog
|
|
79
95
|
// written without titles stays a bulleted list.
|
|
80
96
|
function Notes({ colors, notes }: { colors: Colors; notes: WhatsNewNote[] }) {
|
package/images/5-settings.png
CHANGED
|
Binary file
|
package/index.server.ts
CHANGED
|
@@ -3,9 +3,9 @@ import { handleGetAttention, handleGetDashboard, handleListFolders, handleMarkPa
|
|
|
3
3
|
import { handleInstallLauncher, handleLauncherStatus } from "./server/launcher";
|
|
4
4
|
import { handleCanOpenOnHost, handleOpenDeliverable, handlePreviewDeliverable, handleReadDeliverablePiece } from "./server/open";
|
|
5
5
|
import { mirrorQuestionMode } from "./server/question-mode";
|
|
6
|
-
import { handleInstallSkill, handleSkillStatus } from "./server/skill";
|
|
6
|
+
import { handleInstallSkill, handleReadSkill, handleSkillStatus, repairSkill } from "./server/skill";
|
|
7
7
|
import { handleDismissWhatsNew, handleGetWhatsNew } from "./server/whats-new";
|
|
8
|
-
import { canOpenOnHost, dismissWhatsNew, getAttention, getDashboard, getLauncherStatus, getSkillStatus, getWhatsNew, installLauncher, installSkill, listFolders, markPanelOpened, openDeliverable, previewDeliverable, readDeliverablePiece, setHistoryFolder } from "./shared/rpc";
|
|
8
|
+
import { canOpenOnHost, dismissWhatsNew, getAttention, getDashboard, getLauncherStatus, getSkillStatus, getWhatsNew, installLauncher, installSkill, listFolders, readSkill, markPanelOpened, openDeliverable, previewDeliverable, readDeliverablePiece, setHistoryFolder } from "./shared/rpc";
|
|
9
9
|
import { displaySettings, questionSettings } from "./shared/settings";
|
|
10
10
|
|
|
11
11
|
export default function contribute(server: PluginServerContext) {
|
|
@@ -20,6 +20,7 @@ export default function contribute(server: PluginServerContext) {
|
|
|
20
20
|
server.handle(readDeliverablePiece, handleReadDeliverablePiece);
|
|
21
21
|
server.handle(getSkillStatus, handleSkillStatus);
|
|
22
22
|
server.handle(installSkill, handleInstallSkill);
|
|
23
|
+
server.handle(readSkill, handleReadSkill);
|
|
23
24
|
server.handle(listFolders, handleListFolders);
|
|
24
25
|
server.handle(setHistoryFolder, handleSetHistoryFolder);
|
|
25
26
|
server.handle(getWhatsNew, handleGetWhatsNew);
|
|
@@ -27,6 +28,7 @@ export default function contribute(server: PluginServerContext) {
|
|
|
27
28
|
// Settles a new install before any panel opens. Opening one makes a
|
|
28
29
|
// worktree folder, which would look like an earlier version's.
|
|
29
30
|
void handleGetWhatsNew().catch(() => {});
|
|
31
|
+
void repairSkill();
|
|
30
32
|
server.registerSettings(displaySettings);
|
|
31
33
|
const mirror = mirrorQuestionMode(server.registerSettings(questionSettings));
|
|
32
34
|
return mirror.stop;
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@stevecastaneda/paseo-progress",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.6.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",
|
package/server/skill.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { exec, execFile } from "node:child_process";
|
|
2
|
-
import { lstat, mkdir, readlink, symlink, unlink } from "node:fs/promises";
|
|
2
|
+
import { lstat, mkdir, readFile, readlink, symlink, unlink } from "node:fs/promises";
|
|
3
3
|
import { homedir } from "node:os";
|
|
4
4
|
import { basename, dirname, join, resolve } from "node:path";
|
|
5
5
|
import { promisify } from "node:util";
|
|
@@ -31,24 +31,41 @@ export async function skillState(path: string, pluginDirectory: string): Promise
|
|
|
31
31
|
return basename(target) === SKILL_NAME && basename(dirname(target)) === "skills" ? "outdated" : "foreign";
|
|
32
32
|
}
|
|
33
33
|
|
|
34
|
+
// Repoints each link this plugin made to an old copy at the copy Paseo runs
|
|
35
|
+
// now. Paseo puts every update in a new folder, so this follows each update.
|
|
36
|
+
// Returns whether it wrote.
|
|
37
|
+
export async function relinkSkill(paths: string[], pluginDirectory: string): Promise<boolean> {
|
|
38
|
+
let wrote = false;
|
|
39
|
+
for (const path of paths) {
|
|
40
|
+
if (await skillState(path, pluginDirectory) !== "outdated") continue;
|
|
41
|
+
await unlink(path);
|
|
42
|
+
await link(path, pluginDirectory);
|
|
43
|
+
wrote = true;
|
|
44
|
+
}
|
|
45
|
+
return wrote;
|
|
46
|
+
}
|
|
47
|
+
|
|
34
48
|
// Links the skill wherever it is missing or points at an old copy of the
|
|
35
49
|
// plugin. Leaves alone anything else at those paths. Returns whether it wrote.
|
|
36
50
|
export async function installSkill(paths: string[], pluginDirectory: string): Promise<boolean> {
|
|
37
51
|
const states = await Promise.all(paths.map((path) => skillState(path, pluginDirectory)));
|
|
38
52
|
if (states.every((state) => state === "foreign")) throw new Error(`${paths.join(", ")} already exist and were not linked by this plugin.`);
|
|
39
|
-
let wrote =
|
|
53
|
+
let wrote = await relinkSkill(paths, pluginDirectory);
|
|
40
54
|
for (const [index, path] of paths.entries()) {
|
|
41
|
-
if (states[index] !== "missing"
|
|
42
|
-
|
|
43
|
-
await mkdir(dirname(path), { recursive: true });
|
|
44
|
-
// A junction on Windows, where a directory symlink needs admin rights or
|
|
45
|
-
// Developer Mode. Other systems ignore the type and make a symlink.
|
|
46
|
-
await symlink(skillSource(pluginDirectory), path, "junction");
|
|
55
|
+
if (states[index] !== "missing") continue;
|
|
56
|
+
await link(path, pluginDirectory);
|
|
47
57
|
wrote = true;
|
|
48
58
|
}
|
|
49
59
|
return wrote;
|
|
50
60
|
}
|
|
51
61
|
|
|
62
|
+
async function link(path: string, pluginDirectory: string) {
|
|
63
|
+
await mkdir(dirname(path), { recursive: true });
|
|
64
|
+
// A junction on Windows, where a directory symlink needs admin rights or
|
|
65
|
+
// Developer Mode. Other systems ignore the type and make a symlink.
|
|
66
|
+
await symlink(skillSource(pluginDirectory), path, "junction");
|
|
67
|
+
}
|
|
68
|
+
|
|
52
69
|
// "current" once every folder that can take the link has it.
|
|
53
70
|
export async function skillStatus(paths: string[], pluginDirectory: string) {
|
|
54
71
|
const targets = await Promise.all(paths.map(async (path) => ({ path, state: await skillState(path, pluginDirectory) })));
|
|
@@ -75,19 +92,45 @@ export async function findPluginDirectory(): Promise<string> {
|
|
|
75
92
|
}
|
|
76
93
|
|
|
77
94
|
// On Windows Paseo's command is paseo.cmd, which Node can start, or find on
|
|
78
|
-
// PATH, only through cmd.exe.
|
|
95
|
+
// PATH, only through cmd.exe. A hung CLI must fail so the next one is tried.
|
|
79
96
|
function askPaseo(cli: string) {
|
|
97
|
+
const options = { timeout: 10_000 };
|
|
80
98
|
return process.platform === "win32"
|
|
81
|
-
? promisify(exec)(`"${cli}" plugin ls ${PLUGIN_ID} --json
|
|
82
|
-
: promisify(execFile)(cli, ["plugin", "ls", PLUGIN_ID, "--json"]);
|
|
99
|
+
? promisify(exec)(`"${cli}" plugin ls ${PLUGIN_ID} --json`, options)
|
|
100
|
+
: promisify(execFile)(cli, ["plugin", "ls", PLUGIN_ID, "--json"], options);
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
// Runs once per load, when the server starts: an update reloads the bundle.
|
|
104
|
+
// Only repoints links the user already asked for; new ones wait for Install
|
|
105
|
+
// skill. A failure leaves the links as they were, and status says so.
|
|
106
|
+
let repair: Promise<unknown> | null = null;
|
|
107
|
+
|
|
108
|
+
export function repairSkill(): Promise<unknown> {
|
|
109
|
+
repair ??= findPluginDirectory().then((directory) => relinkSkill(defaultSkillPaths(), directory)).catch(() => {});
|
|
110
|
+
return repair;
|
|
83
111
|
}
|
|
84
112
|
|
|
85
113
|
export async function handleSkillStatus() {
|
|
114
|
+
await repairSkill();
|
|
86
115
|
return skillStatus(defaultSkillPaths(), await findPluginDirectory());
|
|
87
116
|
}
|
|
88
117
|
|
|
89
|
-
//
|
|
118
|
+
// SKILL.md's frontmatter holds one-line fields; only the description is shown.
|
|
119
|
+
export function skillDocument(text: string): { markdown: string; description: string } {
|
|
120
|
+
const frontmatter = /^---\r?\n([\s\S]*?)\r?\n---\r?\n?/.exec(text);
|
|
121
|
+
const description = /^description:[ \t]*(.*)$/m.exec(frontmatter?.[1] ?? "")?.[1].trim().replace(/^(["'])(.*)\1$/, "$2") ?? "";
|
|
122
|
+
return { markdown: text.slice(frontmatter?.[0].length ?? 0).trimStart(), description };
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
export async function handleReadSkill() {
|
|
126
|
+
return skillDocument(await readFile(join(skillSource(await findPluginDirectory()), "SKILL.md"), "utf8"));
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
// Runs only when the user presses Install skill. Links that only need to
|
|
130
|
+
// follow an update are repointed on their own (repairSkill), which must finish
|
|
131
|
+
// first: both would unlink the same outdated link.
|
|
90
132
|
export async function handleInstallSkill() {
|
|
133
|
+
await repairSkill();
|
|
91
134
|
const pluginDirectory = await findPluginDirectory();
|
|
92
135
|
await installSkill(defaultSkillPaths(), pluginDirectory);
|
|
93
136
|
return skillStatus(defaultSkillPaths(), pluginDirectory);
|
package/server/whats-new.ts
CHANGED
|
@@ -1,40 +1,56 @@
|
|
|
1
|
-
// After an update, the panel shows what changed
|
|
2
|
-
// notes come from the changelog in the plugin's own README, so
|
|
3
|
-
// written in one place.
|
|
4
|
-
// computer.
|
|
1
|
+
// After an update, the panel shows what changed since the version last seen,
|
|
2
|
+
// once. The notes come from the changelog in the plugin's own README, so
|
|
3
|
+
// they're written in one place. Which version was seen is kept per user on
|
|
4
|
+
// this computer.
|
|
5
5
|
import { readdir, readFile } from "node:fs/promises";
|
|
6
6
|
import { homedir } from "node:os";
|
|
7
7
|
import { join } from "node:path";
|
|
8
8
|
import { z } from "zod";
|
|
9
|
-
import { NOTE_KINDS, type NoteKind, type WhatsNew, type WhatsNewNote } from "../shared/whats-new.ts";
|
|
9
|
+
import { NOTE_KINDS, type NoteKind, type WhatsNew, type WhatsNewNote, type WhatsNewRelease } from "../shared/whats-new.ts";
|
|
10
10
|
import { spans } from "../shared/write-up.ts";
|
|
11
11
|
import { findPluginDirectory } from "./skill.ts";
|
|
12
12
|
import { stateRoot, writeAtomic } from "./store.ts";
|
|
13
13
|
|
|
14
14
|
export type Release = { version: string; readme: string | null };
|
|
15
15
|
|
|
16
|
-
const seenSchema = z.object({ seen: z.string() });
|
|
16
|
+
const seenSchema = z.object({ seen: z.string().regex(/^\d+\.\d+\.\d+$/) });
|
|
17
17
|
const packageSchema = z.object({ version: z.string() });
|
|
18
18
|
|
|
19
19
|
export function whatsNewPath(home = homedir()): string {
|
|
20
20
|
return join(stateRoot(home), "whats-new.json");
|
|
21
21
|
}
|
|
22
22
|
|
|
23
|
-
//
|
|
24
|
-
//
|
|
25
|
-
export function
|
|
26
|
-
|
|
27
|
-
|
|
23
|
+
// Each `### x.y.z` under the README's `## Changelog` with its top-level
|
|
24
|
+
// bullets, newest first. Versions without bullets are left out.
|
|
25
|
+
export function changelog(readme: string): WhatsNewRelease[] {
|
|
26
|
+
const releases: { version: string; bullets: string[] }[] = [];
|
|
27
|
+
let inChangelog = false;
|
|
28
|
+
let current: string[] | null = null;
|
|
28
29
|
for (const line of readme.replace(/\r\n?/g, "\n").split("\n")) {
|
|
29
|
-
if (
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
30
|
+
if (/^##\s/.test(line)) {
|
|
31
|
+
inChangelog = line.trim() === "## Changelog";
|
|
32
|
+
current = null;
|
|
33
|
+
continue;
|
|
34
|
+
}
|
|
35
|
+
if (!inChangelog) continue;
|
|
36
|
+
if (/^###\s/.test(line)) {
|
|
37
|
+
const version = /^###\s+(\d+\.\d+\.\d+)\s*$/.exec(line)?.[1];
|
|
38
|
+
current = null;
|
|
39
|
+
if (version) releases.push({ version, bullets: (current = []) });
|
|
40
|
+
} else if (current && /^[-*]\s/.test(line)) current.push(line.slice(2).trim());
|
|
41
|
+
else if (current?.length && /^\s+\S/.test(line) && !/^\s+([-*+]|\d+[.)])\s/.test(line)) {
|
|
42
|
+
current[current.length - 1] += ` ${line.trim()}`;
|
|
35
43
|
}
|
|
36
44
|
}
|
|
37
|
-
return
|
|
45
|
+
return releases
|
|
46
|
+
.filter((release) => release.bullets.length)
|
|
47
|
+
.map(({ version, bullets }) => ({ version, notes: bullets.map(note) }))
|
|
48
|
+
.sort((a, b) => compareVersions(b.version, a.version));
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function compareVersions(a: string, b: string): number {
|
|
52
|
+
const [x, y] = [a, b].map((version) => version.split(".").map(Number));
|
|
53
|
+
return x[0] - y[0] || x[1] - y[1] || x[2] - y[2];
|
|
38
54
|
}
|
|
39
55
|
|
|
40
56
|
// A bullet can open with a kind tag, like `[Fixed]`, and then a bold
|
|
@@ -88,18 +104,23 @@ async function usedBefore(home: string): Promise<boolean> {
|
|
|
88
104
|
return entries.some((entry) => entry.isDirectory() && /-[0-9a-f]{8}$/.test(entry.name));
|
|
89
105
|
}
|
|
90
106
|
|
|
91
|
-
// What to show, or null
|
|
92
|
-
// records this version
|
|
107
|
+
// What to show, or null: every version since the one last seen, newest
|
|
108
|
+
// first. A new install has nothing to catch up on, so it records this version
|
|
109
|
+
// as seen. An update from before versions were recorded can't say where it
|
|
110
|
+
// started, so it shows this version alone rather than the whole history.
|
|
93
111
|
export async function whatsNew({ version, readme }: Release, home = homedir()): Promise<WhatsNew | null> {
|
|
94
|
-
const notes = readme === null ? null : changelogNotes(readme, version);
|
|
95
|
-
if (!notes) return null;
|
|
96
112
|
const seen = await readSeen(home);
|
|
97
113
|
if (seen === version) return null;
|
|
98
114
|
if (seen === null && !(await usedBefore(home))) {
|
|
99
115
|
await markWhatsNewSeen(version, home);
|
|
100
116
|
return null;
|
|
101
117
|
}
|
|
102
|
-
|
|
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);
|
|
121
|
+
if (!releases.length) return null;
|
|
122
|
+
const notes = releases.flatMap((release) => release.notes);
|
|
123
|
+
return { version, headline: headline(notes.find((note) => note.kind === "new") ?? notes[0]), releases };
|
|
103
124
|
}
|
|
104
125
|
|
|
105
126
|
// The server bundle can't read files beside it, so ask Paseo where the plugin
|
package/shared/rpc.ts
CHANGED
|
@@ -89,6 +89,14 @@ export const installSkill = defineRpc({
|
|
|
89
89
|
output: skillStatus,
|
|
90
90
|
});
|
|
91
91
|
|
|
92
|
+
// The skill agents get: its SKILL.md without the frontmatter, and the
|
|
93
|
+
// frontmatter's description.
|
|
94
|
+
export const readSkill = defineRpc({
|
|
95
|
+
name: "progress.skill.read",
|
|
96
|
+
input: z.object({}),
|
|
97
|
+
output: z.object({ markdown: z.string(), description: z.string() }),
|
|
98
|
+
});
|
|
99
|
+
|
|
92
100
|
// The folders inside one folder of the worktree ("" is the top), for the
|
|
93
101
|
// folder picker.
|
|
94
102
|
export const listFolders = defineRpc({
|
package/shared/whats-new.ts
CHANGED
|
@@ -13,4 +13,7 @@ export type NoteKind = keyof typeof NOTE_KINDS;
|
|
|
13
13
|
|
|
14
14
|
export type WhatsNewNote = { kind?: NoteKind; title?: string; body: string };
|
|
15
15
|
|
|
16
|
-
export type
|
|
16
|
+
export type WhatsNewRelease = { version: string; notes: WhatsNewNote[] };
|
|
17
|
+
|
|
18
|
+
// `version` is the one running now; `releases` run newest first and are never empty.
|
|
19
|
+
export type WhatsNew = { version: string; headline: string; releases: WhatsNewRelease[] };
|
package/shared/write-up.ts
CHANGED
|
@@ -33,7 +33,9 @@ export function spans(text: string): Span[] {
|
|
|
33
33
|
export function parseWriteUp(markdown: string): Block[] {
|
|
34
34
|
const blocks: Block[] = [];
|
|
35
35
|
let paragraph: string[] = [];
|
|
36
|
-
|
|
36
|
+
// A fence opened inside a list item is indented like the item; its lines
|
|
37
|
+
// lose up to that much indentation.
|
|
38
|
+
let fence: { indent: number; lines: string[] } | null = null;
|
|
37
39
|
const flush = () => {
|
|
38
40
|
if (paragraph.length) blocks.push({ kind: "paragraph", spans: spans(paragraph.join(" ")) });
|
|
39
41
|
paragraph = [];
|
|
@@ -41,14 +43,14 @@ export function parseWriteUp(markdown: string): Block[] {
|
|
|
41
43
|
for (const raw of markdown.replace(/\r\n?/g, "\n").split("\n")) {
|
|
42
44
|
if (fence) {
|
|
43
45
|
if (/^\s*(```|~~~)/.test(raw)) {
|
|
44
|
-
blocks.push({ kind: "code", text: fence.join("\n") });
|
|
46
|
+
blocks.push({ kind: "code", text: fence.lines.join("\n") });
|
|
45
47
|
fence = null;
|
|
46
|
-
} else fence.push(raw);
|
|
48
|
+
} else fence.lines.push(raw.replace(new RegExp(`^[ \\t]{0,${fence.indent}}`), ""));
|
|
47
49
|
continue;
|
|
48
50
|
}
|
|
49
51
|
if (/^\s*(```|~~~)/.test(raw)) {
|
|
50
52
|
flush();
|
|
51
|
-
fence = [];
|
|
53
|
+
fence = { indent: /^[ \t]*/.exec(raw)![0].length, lines: [] };
|
|
52
54
|
continue;
|
|
53
55
|
}
|
|
54
56
|
const line = raw.trimEnd();
|
|
@@ -69,7 +71,7 @@ export function parseWriteUp(markdown: string): Block[] {
|
|
|
69
71
|
} else paragraph.push(line.trim().replace(/^>\s?/, ""));
|
|
70
72
|
}
|
|
71
73
|
flush();
|
|
72
|
-
if (fence) blocks.push({ kind: "code", text: fence.join("\n") });
|
|
74
|
+
if (fence) blocks.push({ kind: "code", text: fence.lines.join("\n") });
|
|
73
75
|
return blocks;
|
|
74
76
|
}
|
|
75
77
|
|