@stevecastaneda/paseo-progress 1.13.0 → 1.14.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 +5 -3
- package/README.md +51 -6
- package/client/chat-row-view.tsx +78 -0
- package/client/chat-row.ts +83 -0
- package/client/dialog-stack.tsx +4 -2
- package/client/launcher.tsx +19 -2
- package/client/locales/en.ts +25 -0
- package/client/locales/zh-CN.ts +25 -0
- package/client/messages.ts +14 -0
- package/client/panel.tsx +25 -12
- package/client/pill-popover.tsx +3 -3
- package/client/settings.tsx +19 -3
- package/client/tabs.tsx +106 -12
- package/client/ticket-dialog.tsx +8 -5
- package/client/write-up.tsx +1 -1
- package/dist/server/cli.js +7 -0
- package/images/5-settings.png +0 -0
- package/images/6-chat-rows.png +0 -0
- package/images/7-empty-tools-note.png +0 -0
- package/index.client.tsx +11 -0
- package/index.server.ts +4 -1
- package/package.json +4 -4
- package/paseo-plugin.json +3 -1
- package/server/cli.ts +8 -0
- package/server/tool.ts +174 -0
- package/shared/rpc.ts +11 -0
- package/shared/tools.ts +23 -0
- package/skills/paseo-progress/SKILL.md +16 -8
package/client/tabs.tsx
CHANGED
|
@@ -1,7 +1,9 @@
|
|
|
1
1
|
import type { PluginWorkspacePanelProps } from "@getpaseo/plugin/client";
|
|
2
2
|
import { Icon } from "@getpaseo/plugin/client/ui";
|
|
3
|
-
import React, { useState } from "react";
|
|
4
|
-
import { Pressable, Text, View } from "react-native";
|
|
3
|
+
import React, { useRef, useState } from "react";
|
|
4
|
+
import { type LayoutChangeEvent, Pressable, ScrollView, Text, View } from "react-native";
|
|
5
|
+
import { useReducedMotion } from "./motion";
|
|
6
|
+
import { withAlpha } from "./surfaces";
|
|
5
7
|
|
|
6
8
|
type Colors = PluginWorkspacePanelProps["theme"]["colors"];
|
|
7
9
|
|
|
@@ -13,39 +15,131 @@ export interface TabEntry<Id extends string> {
|
|
|
13
15
|
count?: number;
|
|
14
16
|
}
|
|
15
17
|
|
|
18
|
+
// What the row does when its tabs don't fit on one line: wrap onto a second line,
|
|
19
|
+
// drop the icons first and wrap only if that's still not enough, or scroll
|
|
20
|
+
// sideways in one row, as on phones, where a faded edge and an arrow show there's more.
|
|
21
|
+
export type TabOverflow = "wrap" | "fit" | "scroll";
|
|
22
|
+
|
|
16
23
|
// A row of tabs over a bottom rule, as in the panel's history card and the
|
|
17
24
|
// ticket dialog. Narrow tightens the spacing so three tabs fit the 320px sidebar.
|
|
18
|
-
|
|
19
|
-
export function TabStrip<Id extends string>({ colors, tabs, selected, onSelect, narrow = false, fit = false, style }: {
|
|
25
|
+
export function TabStrip<Id extends string>({ colors, tabs, selected, onSelect, narrow = false, overflow = "wrap", style }: {
|
|
20
26
|
colors: Colors;
|
|
21
27
|
tabs: Array<TabEntry<Id>>;
|
|
22
28
|
selected: Id;
|
|
23
29
|
onSelect(id: Id): void;
|
|
24
30
|
narrow?: boolean;
|
|
25
|
-
|
|
31
|
+
overflow?: TabOverflow;
|
|
26
32
|
style?: object;
|
|
27
33
|
}) {
|
|
28
34
|
// Set once the tabs wrapped with icons, for this set of tabs.
|
|
29
35
|
const ids = tabs.map((entry) => entry.id).join();
|
|
30
36
|
const [wrappedFor, setWrappedFor] = useState<string | null>(null);
|
|
31
|
-
const
|
|
37
|
+
const scroller = useRef<ScrollView>(null);
|
|
38
|
+
const widths = useRef(new Map<string, number>());
|
|
39
|
+
// The tab the row last scrolled to; null until the first scroll, which jumps.
|
|
40
|
+
const revealed = useRef<string | null>(null);
|
|
41
|
+
const size = useRef({ viewport: 0, content: 0, offset: 0 });
|
|
42
|
+
const [hidden, setHidden] = useState({ before: false, after: false });
|
|
43
|
+
const reduced = useReducedMotion();
|
|
44
|
+
const scroll = overflow === "scroll";
|
|
45
|
+
// Scrolling rows go without icons, so less of the row is hidden.
|
|
46
|
+
const iconless = scroll || (overflow === "fit" && wrappedFor === ids);
|
|
47
|
+
const gap = narrow ? 10 : 16;
|
|
48
|
+
const rule = { borderBottomWidth: 1, borderBottomColor: colors.border };
|
|
49
|
+
// The next scrolling row is a new scroll view, which starts at the left.
|
|
50
|
+
if (!scroll) {
|
|
51
|
+
size.current.offset = 0;
|
|
52
|
+
revealed.current = null;
|
|
53
|
+
if (hidden.before || hidden.after) setHidden({ before: false, after: false });
|
|
54
|
+
}
|
|
55
|
+
// Centers a tab in the visible part of the row, so the ones beside it show too.
|
|
56
|
+
// Positions come from widths, since a tab that moves without resizing never reports it.
|
|
57
|
+
const reveal = (id: string) => {
|
|
58
|
+
const upTo = tabs.slice(0, tabs.findIndex((entry) => entry.id === id) + 1).map((entry) => widths.current.get(entry.id));
|
|
59
|
+
const { viewport } = size.current;
|
|
60
|
+
if (!upTo.length || !viewport || upTo.includes(undefined)) return;
|
|
61
|
+
const width = upTo.pop()!;
|
|
62
|
+
const x = upTo.reduce((sum: number, w) => sum + w! + gap, 0);
|
|
63
|
+
scroller.current?.scrollTo({ x: Math.max(0, x + width / 2 - viewport / 2), animated: !reduced && revealed.current !== null });
|
|
64
|
+
revealed.current = id;
|
|
65
|
+
};
|
|
66
|
+
// Scrolls to the chosen tab once it can be measured, and not again until the choice changes.
|
|
67
|
+
const settle = () => { if (revealed.current !== selected) reveal(selected); };
|
|
68
|
+
// A chevron press moves the row most of its width that way.
|
|
69
|
+
const page = (direction: 1 | -1) => {
|
|
70
|
+
const { viewport, content, offset } = size.current;
|
|
71
|
+
const x = Math.max(0, Math.min(offset + direction * (viewport - 48), content - viewport));
|
|
72
|
+
scroller.current?.scrollTo({ x, animated: !reduced });
|
|
73
|
+
};
|
|
74
|
+
// Which ends have tabs scrolled out of sight, so those edges fade.
|
|
75
|
+
const measure = (next: Partial<typeof size.current>) => {
|
|
76
|
+
const s = Object.assign(size.current, next);
|
|
77
|
+
const before = s.offset > 1;
|
|
78
|
+
const after = s.offset + s.viewport < s.content - 1;
|
|
79
|
+
if (before !== hidden.before || after !== hidden.after) setHidden({ before, after });
|
|
80
|
+
};
|
|
81
|
+
const items = tabs.map((entry) => (
|
|
82
|
+
<Tab key={entry.id} colors={colors} narrow={narrow} entry={iconless ? { ...entry, icon: null } : entry} selected={entry.id === selected}
|
|
83
|
+
onPress={() => { onSelect(entry.id); if (scroll) reveal(entry.id); }}
|
|
84
|
+
onLayout={scroll ? (event) => { widths.current.set(entry.id, event.nativeEvent.layout.width); settle(); } : undefined} />
|
|
85
|
+
));
|
|
86
|
+
if (scroll) {
|
|
87
|
+
// Scrolling rows sit on Paseo's phone sheet, which is surface0.
|
|
88
|
+
return (
|
|
89
|
+
<View style={[{ position: "relative" }, style]}>
|
|
90
|
+
<ScrollView ref={scroller} horizontal showsHorizontalScrollIndicator={false} accessibilityRole="tablist" scrollEventThrottle={16}
|
|
91
|
+
onLayout={(event) => { measure({ viewport: event.nativeEvent.layout.width }); settle(); }}
|
|
92
|
+
onContentSizeChange={(width) => measure({ content: width })}
|
|
93
|
+
onScroll={(event) => measure({ offset: event.nativeEvent.contentOffset.x })}
|
|
94
|
+
contentContainerStyle={{ flexGrow: 1, flexDirection: "row", columnGap: gap, ...rule }}>
|
|
95
|
+
{items}
|
|
96
|
+
</ScrollView>
|
|
97
|
+
{hidden.before ? <EdgeFade colors={colors} side="left" onPress={() => page(-1)} /> : null}
|
|
98
|
+
{hidden.after ? <EdgeFade colors={colors} side="right" onPress={() => page(1)} /> : null}
|
|
99
|
+
</View>
|
|
100
|
+
);
|
|
101
|
+
}
|
|
32
102
|
return (
|
|
33
103
|
<View accessibilityRole="tablist"
|
|
34
104
|
// One row is 32px (8 + 18 + 6); anything taller wrapped.
|
|
35
|
-
onLayout={fit ? (event) => { if (!iconless && event.nativeEvent.layout.height > 40) setWrappedFor(ids); } : undefined}
|
|
36
|
-
style={[{ flexDirection: "row", flexWrap: "wrap", columnGap:
|
|
37
|
-
{
|
|
38
|
-
|
|
105
|
+
onLayout={overflow === "fit" ? (event) => { if (!iconless && event.nativeEvent.layout.height > 40) setWrappedFor(ids); } : undefined}
|
|
106
|
+
style={[{ flexDirection: "row", flexWrap: "wrap", columnGap: gap, ...rule }, style]}>
|
|
107
|
+
{items}
|
|
108
|
+
</View>
|
|
109
|
+
);
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
const FADE_SLICES = 12;
|
|
113
|
+
|
|
114
|
+
// Fades a row that scrolls on past this edge, with a chevron at the edge, so the
|
|
115
|
+
// cue shows even when the next tab sits wholly out of sight. The slices stand in
|
|
116
|
+
// for a gradient, which React Native Web lacks (see pill-popover.tsx).
|
|
117
|
+
function EdgeFade({ colors, side, onPress }: { colors: Colors; side: "left" | "right"; onPress(): void }) {
|
|
118
|
+
return (
|
|
119
|
+
<View pointerEvents="box-none" style={{ position: "absolute", top: 0, bottom: 0, [side]: 0, flexDirection: side === "right" ? "row" : "row-reverse" }}>
|
|
120
|
+
{Array.from({ length: FADE_SLICES }, (_, i) => (
|
|
121
|
+
<View key={i} pointerEvents="none" style={{ width: 2, backgroundColor: withAlpha(colors.surface0, (i + 1) / (FADE_SLICES + 1)) ?? undefined }} />
|
|
39
122
|
))}
|
|
123
|
+
<Pressable accessibilityElementsHidden importantForAccessibility="no-hide-descendants" onPress={onPress} hitSlop={8}
|
|
124
|
+
style={{ width: 16, justifyContent: "center", alignItems: side === "right" ? "flex-end" : "flex-start", backgroundColor: colors.surface0 }}>
|
|
125
|
+
<Icon name={side === "right" ? "ChevronRight" : "ChevronLeft"} size={14} color={colors.foregroundMuted} />
|
|
126
|
+
</Pressable>
|
|
40
127
|
</View>
|
|
41
128
|
);
|
|
42
129
|
}
|
|
43
130
|
|
|
44
131
|
// Instant feedback: tabs switch often, so hover and press change color only.
|
|
45
|
-
function Tab<Id extends string>({ colors, narrow, entry, selected, onPress }: {
|
|
132
|
+
function Tab<Id extends string>({ colors, narrow, entry, selected, onPress, onLayout }: {
|
|
133
|
+
colors: Colors;
|
|
134
|
+
narrow: boolean;
|
|
135
|
+
entry: TabEntry<Id>;
|
|
136
|
+
selected: boolean;
|
|
137
|
+
onPress(): void;
|
|
138
|
+
onLayout?(event: LayoutChangeEvent): void;
|
|
139
|
+
}) {
|
|
46
140
|
const [hovered, setHovered] = useState(false);
|
|
47
141
|
return (
|
|
48
|
-
<Pressable accessibilityRole="tab"
|
|
142
|
+
<Pressable accessibilityRole="tab" aria-selected={selected} onPress={onPress} onLayout={onLayout}
|
|
49
143
|
onHoverIn={() => setHovered(true)} onHoverOut={() => setHovered(false)}
|
|
50
144
|
style={({ pressed }) => ({
|
|
51
145
|
flexDirection: "row", alignItems: "center", gap: narrow ? 4 : 6, paddingTop: 8, paddingBottom: 6, marginBottom: -1,
|
package/client/ticket-dialog.tsx
CHANGED
|
@@ -22,27 +22,30 @@ type AttachmentContext = { workspaceId: string; workspaceDirectory: string; navi
|
|
|
22
22
|
|
|
23
23
|
// A ticket's story, opened from its row: where its time went, and what came out
|
|
24
24
|
// of it. Its questions and previews open in the same dialog (see StackedDialog).
|
|
25
|
-
export function TicketDialogs({ colors, dashboard, openId, setOpenId, now, live, context }: {
|
|
25
|
+
export function TicketDialogs({ colors, dashboard, openId, setOpenId, now, live, compact, context }: {
|
|
26
26
|
colors: Colors;
|
|
27
27
|
dashboard: Dashboard;
|
|
28
28
|
openId: string | null;
|
|
29
29
|
setOpenId(id: string | null): void;
|
|
30
30
|
now: number;
|
|
31
31
|
live: boolean;
|
|
32
|
+
compact: boolean;
|
|
32
33
|
context: AttachmentContext;
|
|
33
34
|
}) {
|
|
34
35
|
return (
|
|
35
36
|
<StackedDialog colors={colors} root={openId ? { kind: "ticket", id: openId } : null} onClose={() => setOpenId(null)}
|
|
36
|
-
questions={[...dashboard.questions.open, ...dashboard.questions.answered]} dashboard={dashboard} now={now} live={live} context={context} />
|
|
37
|
+
questions={[...dashboard.questions.open, ...dashboard.questions.answered]} dashboard={dashboard} now={now} live={live} compact={compact} context={context} />
|
|
37
38
|
);
|
|
38
39
|
}
|
|
39
40
|
|
|
40
|
-
export function TicketStoryView({ colors, dashboard, ticket, now, live, context, onOpenAttachment, onOpenQuestion, onOpenAgent }: {
|
|
41
|
+
export function TicketStoryView({ colors, dashboard, ticket, now, live, compact, context, onOpenAttachment, onOpenQuestion, onOpenAgent }: {
|
|
41
42
|
colors: Colors;
|
|
42
43
|
dashboard: Dashboard;
|
|
43
44
|
ticket: Ticket;
|
|
44
45
|
now: number;
|
|
45
46
|
live: boolean;
|
|
47
|
+
// Paseo's phone-sized layout.
|
|
48
|
+
compact: boolean;
|
|
46
49
|
context: AttachmentContext;
|
|
47
50
|
onOpenAttachment(attachment: Attachment, group: Attachment[]): void;
|
|
48
51
|
onOpenQuestion(id: string): void;
|
|
@@ -138,7 +141,7 @@ export function TicketStoryView({ colors, dashboard, ticket, now, live, context,
|
|
|
138
141
|
</View>
|
|
139
142
|
|
|
140
143
|
<View style={{ gap: 12 }}>
|
|
141
|
-
{tabs.length > 1 ? <TabStrip colors={colors} tabs={tabs} selected={current.id} onSelect={choose} fit /> : null}
|
|
144
|
+
{tabs.length > 1 ? <TabStrip colors={colors} tabs={tabs} selected={current.id} onSelect={choose} overflow={compact ? "scroll" : "fit"} /> : null}
|
|
142
145
|
{current.content}
|
|
143
146
|
</View>
|
|
144
147
|
|
|
@@ -187,7 +190,7 @@ function AgentRow({ colors, agentId, onOpen }: { colors: Colors; agentId: string
|
|
|
187
190
|
<Text numberOfLines={1} style={{ flexShrink: 1, color: colors.foregroundMuted, fontSize: 12, lineHeight: 17 }}>{title}</Text>
|
|
188
191
|
{alert ? <Text numberOfLines={1} style={{ flexShrink: 0, color: alert.danger ? colors.statusDanger : colors.statusWarning, fontSize: 12, lineHeight: 17 }}>{alert.text}</Text> : null}
|
|
189
192
|
<View style={{ flex: 1 }} />
|
|
190
|
-
{open ? <Text style={{ color: colors.foregroundMuted, fontSize: 12, lineHeight: 17, fontWeight: "500" }}>{m.openChat}</Text> : null}
|
|
193
|
+
{open ? <Text numberOfLines={1} style={{ flexShrink: 0, color: colors.foregroundMuted, fontSize: 12, lineHeight: 17, fontWeight: "500" }}>{m.openChat}</Text> : null}
|
|
191
194
|
</PressableRow>
|
|
192
195
|
);
|
|
193
196
|
}
|
package/client/write-up.tsx
CHANGED
|
@@ -71,7 +71,7 @@ function TextButton({ colors, icon, label, accessibilityLabel, onPress, grow = f
|
|
|
71
71
|
colors: Colors; icon?: string; label: string; accessibilityLabel?: string; onPress(): void; grow?: boolean; expanded?: boolean;
|
|
72
72
|
}) {
|
|
73
73
|
return (
|
|
74
|
-
<PressableRow colors={colors} accessibilityRole="button" accessibilityLabel={accessibilityLabel ?? label}
|
|
74
|
+
<PressableRow colors={colors} accessibilityRole="button" accessibilityLabel={accessibilityLabel ?? label} aria-expanded={expanded} onPress={onPress}
|
|
75
75
|
style={{ flexDirection: "row", alignItems: "center", gap: 6, paddingVertical: 3, paddingHorizontal: 6, marginHorizontal: -6, borderRadius: 4, flexShrink: grow ? 1 : 0, minWidth: 0 }}>
|
|
76
76
|
{icon ? <Icon name={icon} size={12} color={colors.foregroundMuted} /> : null}
|
|
77
77
|
<Text numberOfLines={1} style={{ flexShrink: 1, color: colors.foregroundMuted, fontSize: 12, lineHeight: 17, fontWeight: "500" }}>{label}</Text>
|
package/dist/server/cli.js
CHANGED
|
@@ -21055,6 +21055,12 @@ function guessKind(target, isUrl, cwd) {
|
|
|
21055
21055
|
var SHOW_SUMMARY = "Print the dashboard as text: what the user sees in the Progress panel.";
|
|
21056
21056
|
var INSTALL_SUMMARY = "Write the paseo-progress launcher (default ~/.local/bin/paseo-progress). It runs this command from whichever copy of the plugin Paseo is running.";
|
|
21057
21057
|
var DEFAULT_LAUNCHER = "~/.local/bin/paseo-progress";
|
|
21058
|
+
function commandSummary(name) {
|
|
21059
|
+
if (name === "show") return SHOW_SUMMARY;
|
|
21060
|
+
const command = commands[name];
|
|
21061
|
+
if (!command) throw new Error(`No command named ${name}.`);
|
|
21062
|
+
return command.summary;
|
|
21063
|
+
}
|
|
21058
21064
|
function help() {
|
|
21059
21065
|
return [
|
|
21060
21066
|
`Usage: ${USAGE_NAME} <command>`,
|
|
@@ -21166,5 +21172,6 @@ if (isMain()) {
|
|
|
21166
21172
|
);
|
|
21167
21173
|
}
|
|
21168
21174
|
export {
|
|
21175
|
+
commandSummary,
|
|
21169
21176
|
runCli
|
|
21170
21177
|
};
|
package/images/5-settings.png
CHANGED
|
Binary file
|
|
Binary file
|
|
Binary file
|
package/index.client.tsx
CHANGED
|
@@ -7,6 +7,8 @@ import { ProgressLanguageProvider } from "./client/language";
|
|
|
7
7
|
import { addPageScreen } from "./client/page-screen";
|
|
8
8
|
import { PdfPanel } from "./client/pdf";
|
|
9
9
|
import { showsPdfs } from "./shared/attachments";
|
|
10
|
+
import { CHAT_ROW_KIND, CHAT_ROW_VERSION, chatRowSchema, transformChatRow } from "./client/chat-row";
|
|
11
|
+
import { ChatRowView } from "./client/chat-row-view";
|
|
10
12
|
|
|
11
13
|
const SETTINGS_ID = "settings";
|
|
12
14
|
|
|
@@ -45,8 +47,17 @@ export default function contribute(client: PluginClientContext) {
|
|
|
45
47
|
icon: "Settings",
|
|
46
48
|
Component: (props) => <ProgressLanguageProvider><QuestionSettings {...props} /></ProgressLanguageProvider>,
|
|
47
49
|
});
|
|
50
|
+
const stopTransformer = client.addTimelineTransformer({ id: "tool-calls", query: { itemType: "tool_call" }, transform: transformChatRow });
|
|
51
|
+
const stopRenderer = client.addTimelineRenderer({
|
|
52
|
+
kind: CHAT_ROW_KIND,
|
|
53
|
+
version: CHAT_ROW_VERSION,
|
|
54
|
+
schema: chatRowSchema,
|
|
55
|
+
Component: (props) => <ProgressLanguageProvider><ChatRowView {...props} /></ProgressLanguageProvider>,
|
|
56
|
+
});
|
|
48
57
|
|
|
49
58
|
return () => {
|
|
59
|
+
void stopTransformer();
|
|
60
|
+
void stopRenderer();
|
|
50
61
|
stopPage();
|
|
51
62
|
void stopPdf();
|
|
52
63
|
void stopSettings();
|
package/index.server.ts
CHANGED
|
@@ -4,8 +4,9 @@ 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
6
|
import { handleInstallSkill, handleReadSkill, handleSkillStatus, repairSkill } from "./server/skill";
|
|
7
|
+
import { attachProgressTools, handleToolsStatus } from "./server/tool";
|
|
7
8
|
import { handleDismissWhatsNew, handleGetWhatsNew } from "./server/whats-new";
|
|
8
|
-
import { canOpenOnHost, dismissWhatsNew, getAttention, getDashboard, getLauncherStatus, getSkillStatus, getWhatsNew, installLauncher, installSkill, listFolders, readSkill, markPanelOpened, openDeliverable, previewDeliverable, readDeliverablePiece, setHistoryFolder } from "./shared/rpc";
|
|
9
|
+
import { canOpenOnHost, dismissWhatsNew, getAttention, getDashboard, getLauncherStatus, getSkillStatus, getToolsStatus, getWhatsNew, installLauncher, installSkill, listFolders, readSkill, markPanelOpened, openDeliverable, previewDeliverable, readDeliverablePiece, setHistoryFolder } from "./shared/rpc";
|
|
9
10
|
import { displaySettings, questionSettings } from "./shared/settings";
|
|
10
11
|
|
|
11
12
|
export default function contribute(server: PluginServerContext) {
|
|
@@ -23,12 +24,14 @@ export default function contribute(server: PluginServerContext) {
|
|
|
23
24
|
server.handle(readSkill, handleReadSkill);
|
|
24
25
|
server.handle(listFolders, handleListFolders);
|
|
25
26
|
server.handle(setHistoryFolder, handleSetHistoryFolder);
|
|
27
|
+
server.handle(getToolsStatus, handleToolsStatus);
|
|
26
28
|
server.handle(getWhatsNew, handleGetWhatsNew);
|
|
27
29
|
server.handle(dismissWhatsNew, handleDismissWhatsNew);
|
|
28
30
|
// Settles a new install before any panel opens. Opening one makes a
|
|
29
31
|
// worktree folder, which would look like an earlier version's.
|
|
30
32
|
void handleGetWhatsNew().catch(() => {});
|
|
31
33
|
void repairSkill();
|
|
34
|
+
attachProgressTools(server);
|
|
32
35
|
server.registerSettings(displaySettings);
|
|
33
36
|
const mirror = mirrorQuestionMode(server.registerSettings(questionSettings));
|
|
34
37
|
return mirror.stop;
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@stevecastaneda/paseo-progress",
|
|
3
|
-
"version": "1.
|
|
4
|
-
"description": "A live progress dashboard per worktree for Paseo, drawn from a file agents write with the paseo-progress command.",
|
|
3
|
+
"version": "1.14.0",
|
|
4
|
+
"description": "A live progress dashboard per worktree for Paseo, drawn from a file agents write with the Progress tools or the paseo-progress command.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"author": "Steve Castaneda",
|
|
7
7
|
"repository": {
|
|
@@ -50,8 +50,8 @@
|
|
|
50
50
|
"postpack": "rm -rf dist"
|
|
51
51
|
},
|
|
52
52
|
"devDependencies": {
|
|
53
|
-
"@getpaseo/client": "0.
|
|
54
|
-
"@getpaseo/plugin": "0.
|
|
53
|
+
"@getpaseo/client": "0.14.0",
|
|
54
|
+
"@getpaseo/plugin": "0.14.0",
|
|
55
55
|
"@tanstack/react-query": "^5.90.11",
|
|
56
56
|
"@types/node": "^20.9.0",
|
|
57
57
|
"@types/react": "~19.2.0",
|
package/paseo-plugin.json
CHANGED
package/server/cli.ts
CHANGED
|
@@ -494,6 +494,14 @@ const SHOW_SUMMARY = "Print the dashboard as text: what the user sees in the Pro
|
|
|
494
494
|
const INSTALL_SUMMARY = "Write the paseo-progress launcher (default ~/.local/bin/paseo-progress). It runs this command from whichever copy of the plugin Paseo is running.";
|
|
495
495
|
const DEFAULT_LAUNCHER = "~/.local/bin/paseo-progress";
|
|
496
496
|
|
|
497
|
+
// The agent tools describe themselves with the summaries agents see in --help.
|
|
498
|
+
export function commandSummary(name: string): string {
|
|
499
|
+
if (name === "show") return SHOW_SUMMARY;
|
|
500
|
+
const command = commands[name];
|
|
501
|
+
if (!command) throw new Error(`No command named ${name}.`);
|
|
502
|
+
return command.summary;
|
|
503
|
+
}
|
|
504
|
+
|
|
497
505
|
function help(): string {
|
|
498
506
|
return [
|
|
499
507
|
`Usage: ${USAGE_NAME} <command>`,
|
package/server/tool.ts
ADDED
|
@@ -0,0 +1,174 @@
|
|
|
1
|
+
import type { AgentToolContext, AgentToolDefinition, AgentToolResult, PluginHandlerContext, PluginServerContext } from "@getpaseo/plugin/server";
|
|
2
|
+
import * as sdk from "@getpaseo/plugin/server";
|
|
3
|
+
import { z } from "zod";
|
|
4
|
+
import { DELIVERABLE_KINDS, ticketStatusSchema } from "../shared/events.ts";
|
|
5
|
+
import type { ToolsState } from "../shared/rpc.ts";
|
|
6
|
+
import { PROGRESS_TOOL_NAMES, type ProgressToolName } from "../shared/tools.ts";
|
|
7
|
+
import { commandSummary, runCli } from "./cli.ts";
|
|
8
|
+
|
|
9
|
+
// Paseo supplies this SDK at runtime. Paseo 0.13's has no defineAgentTool and
|
|
10
|
+
// can't give agents plugin tools; reading the name calls nothing, so it's safe there.
|
|
11
|
+
export const agentToolsSupported = "defineAgentTool" in sdk;
|
|
12
|
+
|
|
13
|
+
// Importing defineAgentTool would stop Progress loading on Paseo 0.13. This
|
|
14
|
+
// mirrors its typing; Paseo validates the input before execute.
|
|
15
|
+
type Tool = Omit<AgentToolDefinition, "name">;
|
|
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 {
|
|
21
|
+
return { ...tool, execute: (input, context) => tool.execute(input as z.output<Input>, context) };
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
async function run(args: string[], { agent }: AgentToolContext): Promise<AgentToolResult> {
|
|
25
|
+
const lines: string[] = [];
|
|
26
|
+
const code = await runCli(args, { cwd: agent.cwd, agentId: agent.id, out: (line) => lines.push(line) });
|
|
27
|
+
return { text: lines.join("\n"), isError: code !== 0 };
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
type Flags = Record<string, string | number | string[] | undefined>;
|
|
31
|
+
|
|
32
|
+
// Flags go as --name=value so a value starting with "-" stays a value, and the
|
|
33
|
+
// positionals follow "--" so a title starting with "-" isn't read as a flag.
|
|
34
|
+
function commandArgs(command: string, positionals: string[], flags: Flags = {}): string[] {
|
|
35
|
+
const options = Object.entries(flags).flatMap(([name, value]) => (value === undefined ? [] : [value].flat().map((entry) => `--${name}=${entry}`)));
|
|
36
|
+
return [...command.split(" "), ...options, "--", ...positionals];
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
// The command's --help summary, with its flags renamed to this tool's fields.
|
|
40
|
+
const FIELD_FOR_FLAG: Record<string, string> = { estimate: "estimateMinutes", option: "options", file: "files" };
|
|
41
|
+
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
|
+
}
|
|
45
|
+
|
|
46
|
+
function commandTool<Input extends z.ZodType>(title: string, command: string, input: Input, args: (input: z.output<Input>) => string[], extra = "") {
|
|
47
|
+
return agentTool({
|
|
48
|
+
title: `Progress: ${title}`,
|
|
49
|
+
description: `Progress dashboard for this worktree in Paseo. ${toolSummary(command)}${extra}`,
|
|
50
|
+
input,
|
|
51
|
+
execute: (value, context) => run(args(value), context),
|
|
52
|
+
});
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
const ticketId = (what: string) => z.string().describe(`${what}, like T03`);
|
|
56
|
+
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");
|
|
58
|
+
|
|
59
|
+
// Keyed by name, so the compiler holds this to the shared list.
|
|
60
|
+
const tools: Record<ProgressToolName, Tool> = {
|
|
61
|
+
progress_show: commandTool("Show", "show", z.object({}), () => ["show"]),
|
|
62
|
+
progress_start: commandTool("Start run", "start",
|
|
63
|
+
z.object({
|
|
64
|
+
title: z.string(),
|
|
65
|
+
subtitle: z.string().optional(),
|
|
66
|
+
itemLabel: z.string().optional().describe('What to call each item instead of "Ticket", like Step'),
|
|
67
|
+
}),
|
|
68
|
+
({ title, subtitle, itemLabel }) => commandArgs("start", [title], { subtitle, "item-label": itemLabel })),
|
|
69
|
+
progress_ticket_add: commandTool("Add ticket", "ticket add",
|
|
70
|
+
z.object({
|
|
71
|
+
title: z.string().describe('Like "Ticket 01: Export button"'),
|
|
72
|
+
estimateMinutes: estimate,
|
|
73
|
+
status: ticketStatusSchema.optional(),
|
|
74
|
+
waitsFor: ticketId("A ticket this one can't start before").optional(),
|
|
75
|
+
source: source.optional(),
|
|
76
|
+
}),
|
|
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",
|
|
79
|
+
z.object({
|
|
80
|
+
id: ticketId("The ticket"),
|
|
81
|
+
status: ticketStatusSchema.optional(),
|
|
82
|
+
stage: z.string().optional().describe('The step in progress, like Build or Fixes; "" clears it'),
|
|
83
|
+
title: z.string().optional(),
|
|
84
|
+
estimateMinutes: estimate.optional(),
|
|
85
|
+
note: z.string().optional(),
|
|
86
|
+
waitsFor: z.string().optional().describe('A ticket this one can\'t start before, like T02; "" clears it'),
|
|
87
|
+
source: z.string().optional().describe('Spec file path or http(s) link where the ticket is written up; "" clears it'),
|
|
88
|
+
}),
|
|
89
|
+
({ 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",
|
|
92
|
+
z.object({
|
|
93
|
+
title: z.string().describe("A short title"),
|
|
94
|
+
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
|
+
})),
|
|
100
|
+
recommend: z.string().describe("The option letter you would pick"),
|
|
101
|
+
big: z.string().optional().describe("Only for a big question: which test it meets, in a few plain words"),
|
|
102
|
+
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(),
|
|
107
|
+
ticket: ticketId("The ticket it's about").optional(),
|
|
108
|
+
}),
|
|
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",
|
|
118
|
+
z.object({
|
|
119
|
+
id: z.string().describe("The question, like Q2"),
|
|
120
|
+
choice: z.string().describe("An option letter, or the user's own short answer"),
|
|
121
|
+
words: z.string().optional().describe("What the user said"),
|
|
122
|
+
}),
|
|
123
|
+
({ id, choice, words }) => commandArgs("question answer", [id, choice], { words })),
|
|
124
|
+
progress_deliverable_add: commandTool("Add deliverable", "deliverable add",
|
|
125
|
+
z.object({
|
|
126
|
+
title: z.string(),
|
|
127
|
+
target: z.string().describe("A file or folder path, or an http(s) link"),
|
|
128
|
+
ticket: ticketId("The ticket it belongs to").optional(),
|
|
129
|
+
kind: z.enum(DELIVERABLE_KINDS).optional(),
|
|
130
|
+
}),
|
|
131
|
+
({ title, target, ticket, kind }) => commandArgs("deliverable add", [title, target], { ticket, kind })),
|
|
132
|
+
progress_activity_add: commandTool("Log activity", "activity add",
|
|
133
|
+
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",
|
|
136
|
+
z.object({ text: z.string() }),
|
|
137
|
+
({ 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])),
|
|
142
|
+
paseo_progress: agentTool({
|
|
143
|
+
title: "Progress",
|
|
144
|
+
description: [
|
|
145
|
+
"Record progress for this worktree on the Progress panel in Paseo. Works like the paseo-progress command:",
|
|
146
|
+
'pass its arguments as a list, like ["ticket", "add", "Fix login", "--estimate", "30"].',
|
|
147
|
+
'Pass ["--help"] to list the commands, or add "--help" after any command for its options.',
|
|
148
|
+
].join(" "),
|
|
149
|
+
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),
|
|
151
|
+
}),
|
|
152
|
+
};
|
|
153
|
+
|
|
154
|
+
export const progressTools: AgentToolDefinition[] = PROGRESS_TOOL_NAMES.map((name) => ({ name, ...tools[name] }));
|
|
155
|
+
|
|
156
|
+
export function attachProgressTools(server: PluginServerContext): void {
|
|
157
|
+
server.before("agent.session_open", ({ request }) => {
|
|
158
|
+
// Paseo 0.13 has no tools list and fails the session open if a plugin adds one.
|
|
159
|
+
if (!Array.isArray(request.tools)) return;
|
|
160
|
+
return { ...request, tools: [...request.tools, ...progressTools] };
|
|
161
|
+
});
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
// Tools reach agents only while the host's "Enable Paseo tools" setting is on,
|
|
165
|
+
// which needs Paseo's tools (mcp.enabled) on as well.
|
|
166
|
+
export async function toolsState(supported: boolean, paseo: PluginHandlerContext["paseo"]): Promise<ToolsState> {
|
|
167
|
+
if (!supported) return "unsupported";
|
|
168
|
+
const { config } = await paseo.config.get();
|
|
169
|
+
return config.mcp.enabled !== false && config.mcp.injectIntoAgents ? "on" : "off";
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
export async function handleToolsStatus(_input: unknown, { paseo }: PluginHandlerContext): Promise<{ state: ToolsState }> {
|
|
173
|
+
return { state: await toolsState(agentToolsSupported, paseo) };
|
|
174
|
+
}
|
package/shared/rpc.ts
CHANGED
|
@@ -69,6 +69,17 @@ export const installLauncher = defineRpc({
|
|
|
69
69
|
output: launcherStatus,
|
|
70
70
|
});
|
|
71
71
|
|
|
72
|
+
// Whether agents get the Progress tools: "unsupported" before Paseo 0.14, then
|
|
73
|
+
// whether the host's "Enable Paseo tools" setting is "on" or "off".
|
|
74
|
+
export const TOOLS_STATES = ["unsupported", "off", "on"] as const;
|
|
75
|
+
export type ToolsState = (typeof TOOLS_STATES)[number];
|
|
76
|
+
|
|
77
|
+
export const getToolsStatus = defineRpc({
|
|
78
|
+
name: "progress.tools.status",
|
|
79
|
+
input: z.object({}),
|
|
80
|
+
output: z.object({ state: z.enum(TOOLS_STATES) }),
|
|
81
|
+
});
|
|
82
|
+
|
|
72
83
|
// The paseo-progress skill, linked into each agent's skills folder.
|
|
73
84
|
const skillState = z.enum(["missing", "current", "outdated", "foreign"]);
|
|
74
85
|
const skillStatus = z.object({
|
package/shared/tools.ts
ADDED
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
// The tools Progress hands agents on Paseo 0.14 and later. The server defines
|
|
2
|
+
// one tool per name, and the chat draws Progress's own row for a call to any of them.
|
|
3
|
+
export const PROGRESS_TOOL_NAMES = [
|
|
4
|
+
"progress_show",
|
|
5
|
+
"progress_start",
|
|
6
|
+
"progress_ticket_add",
|
|
7
|
+
"progress_ticket_update",
|
|
8
|
+
"progress_question_ask",
|
|
9
|
+
"progress_question_answer",
|
|
10
|
+
"progress_deliverable_add",
|
|
11
|
+
"progress_activity_add",
|
|
12
|
+
"progress_ticker",
|
|
13
|
+
"progress_finish",
|
|
14
|
+
"paseo_progress",
|
|
15
|
+
] as const;
|
|
16
|
+
|
|
17
|
+
export type ProgressToolName = (typeof PROGRESS_TOOL_NAMES)[number];
|
|
18
|
+
|
|
19
|
+
// Chats record a tool under the agent's own prefix: Claude Code's
|
|
20
|
+
// mcp__paseo__progress_ticket_add, or Codex's paseo.progress_ticket_add.
|
|
21
|
+
export function progressToolName(name: string): ProgressToolName | undefined {
|
|
22
|
+
return PROGRESS_TOOL_NAMES.find((tool) => name === tool || name.endsWith(`__${tool}`) || name.endsWith(`.${tool}`));
|
|
23
|
+
}
|
|
@@ -1,15 +1,19 @@
|
|
|
1
1
|
---
|
|
2
2
|
name: paseo-progress
|
|
3
|
-
description: Keep the Paseo Progress dashboard live with the paseo-progress command. Use when working through a spec or a set of tickets, on any job over 5 steps or 30 minutes, or when the user asks for a progress dashboard.
|
|
3
|
+
description: Keep the Paseo Progress dashboard live with the Progress tools or the paseo-progress command. Use when working through a spec or a set of tickets, on any job over 5 steps or 30 minutes, or when the user asks for a progress dashboard.
|
|
4
4
|
---
|
|
5
5
|
|
|
6
6
|
# Paseo progress
|
|
7
7
|
|
|
8
|
-
The user watches a live dashboard in Paseo for this worktree. Keep it live:
|
|
8
|
+
The user watches a live dashboard in Paseo for this worktree. Keep it live: record each change the moment it happens, so the dashboard is a side effect of the work. Write every line for a product manager, in plain words.
|
|
9
|
+
|
|
10
|
+
Use the Progress tools when you have them: `progress_show`, `progress_start`, `progress_ticket_add`, `progress_ticket_update`, `progress_question_ask`, `progress_question_answer`, `progress_deliverable_add`, `progress_activity_add`, `progress_ticker`, `progress_finish`, and `paseo_progress` for any other command. Paseo hands them to agents when the user turns on Paseo tools. Claude Code names them like `mcp__paseo__progress_show` and keeps them hidden until you search, so search your tools for `progress_` before your first update. Otherwise, run the `paseo-progress` command from inside the worktree. When a tool call says the tool isn't found, use the command for the rest of that reply.
|
|
11
|
+
|
|
12
|
+
The tools and the command take the same values and print the same answers. The steps below use the command's words: `--estimate` is `estimateMinutes` in the tools, `--waits-for` is `waitsFor`. `paseo-progress --help` is the reference for every command and flag.
|
|
9
13
|
|
|
10
14
|
## 1. Set up
|
|
11
15
|
|
|
12
|
-
Run `
|
|
16
|
+
Run `show` and continue an open run for this job. A Finished run is closed, so `start` a new one, then add every ticket in order:
|
|
13
17
|
|
|
14
18
|
- Title each `"Ticket 01: <title>"`. The dashboard shortens titles to that prefix.
|
|
15
19
|
- Give each an `--estimate`, plus `--waits-for` when it can't start before another and `--source` when a spec file or issue describes it.
|
|
@@ -27,16 +31,20 @@ Move each ticket through `--status working --stage Build`, then the stages `Revi
|
|
|
27
31
|
- Put this in a subagent's prompt, with the worktree path and ticket id filled in. While you wait on it, its updates are the dashboard's only sign the work is moving:
|
|
28
32
|
|
|
29
33
|
```text
|
|
30
|
-
Record progress on the Paseo dashboard as you work
|
|
31
|
-
|
|
32
|
-
|
|
34
|
+
Record progress on the Paseo dashboard as you work. Use the Progress tools if you
|
|
35
|
+
have them (search your tools for progress_), otherwise the paseo-progress command.
|
|
36
|
+
If a tool call says the tool isn't found, use the command for the rest of that reply.
|
|
37
|
+
- at each new stage: progress_ticket_update with id T03 and stage <stage>,
|
|
38
|
+
or: cd <worktree> && paseo-progress ticket update T03 --stage <stage>
|
|
39
|
+
- between stages: progress_ticker with "<what you are doing now>",
|
|
40
|
+
or: cd <worktree> && paseo-progress ticker set "<what you are doing now>"
|
|
33
41
|
```
|
|
34
42
|
|
|
35
43
|
Done when every ticket is done or skipped, or waits on a question.
|
|
36
44
|
|
|
37
45
|
## 3. Ask
|
|
38
46
|
|
|
39
|
-
When a decision belongs to the user, ask it with `question ask`, lettered `--option`s and your `--recommend`. Read `paseo-progress question ask --help` before your first question.
|
|
47
|
+
When a decision belongs to the user, ask it with `question ask`, lettered `--option`s and your `--recommend`. Read the `progress_question_ask` description or `paseo-progress question ask --help` before your first question. Both define a big question, which you mark with `--big "<the test it meets>"`.
|
|
40
48
|
|
|
41
49
|
The user picks the question mode in Paseo's Settings, and `question ask` prints what that mode means for this question. Do what it prints. Record the user's reply ("Q7 A", or their own words) with `question answer Q7 A --words "<their words>"`, and do what that prints too.
|
|
42
50
|
|
|
@@ -45,6 +53,6 @@ The user picks the question mode in Paseo's Settings, and `question ask` prints
|
|
|
45
53
|
|
|
46
54
|
## 4. Finish
|
|
47
55
|
|
|
48
|
-
The job is over when every ticket is done or skipped and every question is answered. While questions wait on the user, leave the run open and tell the user in chat which ones. Then
|
|
56
|
+
The job is over when every ticket is done or skipped and every question is answered. While questions wait on the user, leave the run open and tell the user in chat which ones. Then `finish` with a one-line outcome, and settle whatever it lists if it refuses.
|
|
49
57
|
|
|
50
58
|
Done when `show` says Finished with the right outcome.
|