paseo-beads 0.1.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.
@@ -0,0 +1,51 @@
1
+ /**
2
+ * Lets a slash command or Command Center item pre-select an issue in the panel.
3
+ * Module state is shared inside one client bundle, so the panel can read the
4
+ * request the command wrote just before opening it.
5
+ */
6
+ type Listener = () => void;
7
+
8
+ const listeners = new Set<Listener>();
9
+ const requests = new Map<string, string>();
10
+ let revision = 0;
11
+ const refreshRequests = new Set<string>();
12
+ let refreshRevision = 0;
13
+
14
+ export function requestIssueFocus(workspaceId: string, issueId: string): void {
15
+ requests.set(workspaceId, issueId);
16
+ revision += 1;
17
+ for (const listener of listeners) listener();
18
+ }
19
+
20
+ /** Reads and clears a pending focus request. */
21
+ export function takeIssueFocus(workspaceId: string): string | null {
22
+ const issueId = requests.get(workspaceId);
23
+ if (issueId === undefined) return null;
24
+ requests.delete(workspaceId);
25
+ return issueId;
26
+ }
27
+
28
+ export function subscribeIssueFocus(listener: Listener): () => void {
29
+ listeners.add(listener);
30
+ return () => {
31
+ listeners.delete(listener);
32
+ };
33
+ }
34
+
35
+ export function requestDashboardRefresh(workspaceId: string): void {
36
+ refreshRequests.add(workspaceId);
37
+ refreshRevision += 1;
38
+ for (const listener of listeners) listener();
39
+ }
40
+
41
+ export function takeDashboardRefresh(workspaceId: string): boolean {
42
+ return refreshRequests.delete(workspaceId);
43
+ }
44
+
45
+ export function dashboardRefreshRevision(): number {
46
+ return refreshRevision;
47
+ }
48
+
49
+ export function issueFocusRevision(): number {
50
+ return revision;
51
+ }
@@ -0,0 +1,227 @@
1
+ import type { PluginTheme } from "@getpaseo/plugin";
2
+ import { BEADS_STATUSES, type Alert, type CommandError, type SourceAuthority } from "../shared/beads";
3
+ import type { WorkState } from "./project";
4
+
5
+ /** Semantic accent used by the status rail and severity marks. */
6
+ export type Tone = "neutral" | "accent" | "success" | "warning" | "danger";
7
+
8
+ export function toneColor(theme: PluginTheme, tone: Tone): string {
9
+ switch (tone) {
10
+ case "accent":
11
+ return theme.colors.accent;
12
+ case "success":
13
+ return theme.colors.statusSuccess;
14
+ case "warning":
15
+ return theme.colors.statusWarning;
16
+ case "danger":
17
+ return theme.colors.statusDanger;
18
+ case "neutral":
19
+ return theme.colors.border;
20
+ }
21
+ }
22
+
23
+ /** Priority colour, for the priority dot and for rows that carry no work state. */
24
+ export function priorityTone(priority: number | null): Tone {
25
+ if (priority === null) return "neutral";
26
+ if (priority <= 0) return "danger";
27
+ if (priority === 1) return "warning";
28
+ if (priority === 2) return "accent";
29
+ return "neutral";
30
+ }
31
+
32
+ /**
33
+ * Lucide icon for a status. Only Beads' built-in statuses get their own icon;
34
+ * a project's custom status gets the neutral dashed circle rather than a
35
+ * borrowed meaning.
36
+ */
37
+ export function statusIconName(status: string): string {
38
+ switch (status.trim().toLowerCase()) {
39
+ case BEADS_STATUSES.inProgress:
40
+ case BEADS_STATUSES.hooked:
41
+ return "Play";
42
+ case BEADS_STATUSES.blocked:
43
+ case BEADS_STATUSES.deferred:
44
+ case BEADS_STATUSES.draft:
45
+ case BEADS_STATUSES.pinned:
46
+ return "CircleSlash";
47
+ case BEADS_STATUSES.open:
48
+ return "Circle";
49
+ case BEADS_STATUSES.closed:
50
+ return "CircleCheck";
51
+ case BEADS_STATUSES.tombstone:
52
+ return "CircleX";
53
+ default:
54
+ return "CircleDashed";
55
+ }
56
+ }
57
+
58
+ /** Display form of an opaque status: underscores and dashes read as spaces. */
59
+ export function statusLabel(status: string): string {
60
+ const trimmed = status.trim();
61
+ if (trimmed.length === 0) return "unknown";
62
+ return trimmed.replace(/[_-]+/g, " ");
63
+ }
64
+
65
+ export function severityTone(severity: string): Tone {
66
+ switch (severity.toLowerCase()) {
67
+ case "critical":
68
+ return "danger";
69
+ case "warning":
70
+ return "warning";
71
+ default:
72
+ return "neutral";
73
+ }
74
+ }
75
+
76
+ export function authorityTone(authority: SourceAuthority | null): Tone {
77
+ if (authority === null) return "neutral";
78
+ if (authority.failed > 0 || authority.state === "unknown") return "danger";
79
+ if (authority.stale || authority.readiness !== "proven" || !authority.claimSafe) return "warning";
80
+ return "success";
81
+ }
82
+
83
+ export function authorityLabel(authority: SourceAuthority | null): string {
84
+ if (authority === null) return "authority unknown";
85
+ const readiness = authority.readiness;
86
+ const freshness = authority.stale ? "stale" : "fresh";
87
+ return `${authority.state} · ${readiness} · ${freshness}`;
88
+ }
89
+
90
+ export function priorityLabel(priority: number | null): string | null {
91
+ return priority === null ? null : `P${priority}`;
92
+ }
93
+
94
+ export function shortHash(hash: string | null): string | null {
95
+ if (hash === null || hash.length === 0) return null;
96
+ return hash.slice(0, 8);
97
+ }
98
+
99
+ /** Compact relative age for a timestamp emitted by `bv`. */
100
+ export function relativeAge(timestamp: string | null, now: number = Date.now()): string | null {
101
+ if (timestamp === null) return null;
102
+ const parsed = Date.parse(timestamp);
103
+ if (Number.isNaN(parsed)) return null;
104
+ const seconds = Math.max(0, Math.round((now - parsed) / 1000));
105
+ if (seconds < 60) return `${seconds}s ago`;
106
+ const minutes = Math.round(seconds / 60);
107
+ if (minutes < 60) return `${minutes}m ago`;
108
+ const hours = Math.round(minutes / 60);
109
+ if (hours < 48) return `${hours}h ago`;
110
+ return `${Math.round(hours / 24)}d ago`;
111
+ }
112
+
113
+ export function errorLabel(error: CommandError | null): string {
114
+ if (error === null) return "Unavailable.";
115
+ switch (error.code) {
116
+ case "unavailable":
117
+ return `bv is unavailable. ${error.message}`;
118
+ case "timeout":
119
+ return `The analysis timed out. ${error.message}`;
120
+ case "output_limit":
121
+ return `The analysis produced too much output. ${error.message}`;
122
+ case "invalid_json":
123
+ return `The analysis output could not be read. ${error.message}`;
124
+ case "cwd_invalid":
125
+ case "workspace_unresolved":
126
+ return `This workspace could not be resolved. ${error.message}`;
127
+ case "tracker_unknown":
128
+ return error.message;
129
+ case "exit":
130
+ case "internal":
131
+ return error.message;
132
+ }
133
+ }
134
+
135
+ export function alertHeadline(alert: Alert): string {
136
+ return alert.issueId === null ? alert.message : `${alert.issueId} — ${alert.message}`;
137
+ }
138
+
139
+ /**
140
+ * Derived work state carries the row colour: it is the one fact that tells
141
+ * cards apart on a project where every issue shares a priority.
142
+ */
143
+ export function stateTone(state: WorkState): Tone {
144
+ switch (state) {
145
+ case "active":
146
+ return "accent";
147
+ case "ready":
148
+ return "success";
149
+ case "held":
150
+ return "danger";
151
+ case "other":
152
+ return "warning";
153
+ case "waiting":
154
+ case "done":
155
+ return "neutral";
156
+ }
157
+ }
158
+
159
+ export function stateLabel(state: WorkState): string {
160
+ switch (state) {
161
+ case "active":
162
+ return "In progress";
163
+ case "ready":
164
+ return "Ready";
165
+ case "waiting":
166
+ return "Waiting";
167
+ case "held":
168
+ return "Held";
169
+ case "other":
170
+ return "Other status";
171
+ case "done":
172
+ return "Done";
173
+ }
174
+ }
175
+
176
+ /**
177
+ * What each state means, in Beads' own terms, shown under the column title so
178
+ * nobody has to guess. Ready and Waiting match `br ready` and `br blocked`.
179
+ */
180
+ export function stateDescription(state: WorkState): string {
181
+ switch (state) {
182
+ case "ready":
183
+ return "Status open, and nothing it depends on is still open (br ready).";
184
+ case "waiting":
185
+ return "Status open, but a dependency, or its parent's, is still open (br blocked).";
186
+ case "active":
187
+ return "Status in_progress or hooked: someone has claimed it.";
188
+ case "held":
189
+ return "Status set by hand to blocked, deferred, draft or pinned.";
190
+ case "other":
191
+ return "A custom status Beads does not define, shown as written.";
192
+ case "done":
193
+ return "Status closed.";
194
+ }
195
+ }
196
+
197
+ export function stateIconName(state: WorkState): string {
198
+ switch (state) {
199
+ case "active":
200
+ return "Play";
201
+ case "ready":
202
+ return "CircleDot";
203
+ case "waiting":
204
+ return "Circle";
205
+ case "held":
206
+ return "CircleSlash";
207
+ case "other":
208
+ return "CircleDashed";
209
+ case "done":
210
+ return "CircleCheck";
211
+ }
212
+ }
213
+
214
+ /** "waits on a, b +3": the open blockers themselves, not just how many. */
215
+ export function waitsOnLabel(blockedBy: readonly string[], shown = 2): string | null {
216
+ if (blockedBy.length === 0) return null;
217
+ const head = blockedBy.slice(0, shown).join(", ");
218
+ const rest = blockedBy.length - shown;
219
+ return rest > 0 ? `waits on ${head} +${rest}` : `waits on ${head}`;
220
+ }
221
+
222
+ /** Whole-number percentage, never rounding unfinished work up to 100. */
223
+ export function percentDone(done: number, total: number): number {
224
+ if (total <= 0) return 0;
225
+ const percent = Math.round((done / total) * 100);
226
+ return done < total ? Math.min(percent, 99) : percent;
227
+ }
@@ -0,0 +1,156 @@
1
+ import type { PluginTheme } from "@getpaseo/plugin";
2
+ import { Icon } from "@getpaseo/plugin/client/react-native";
3
+ import { Text, View } from "react-native";
4
+ import {
5
+ parseMarkdown,
6
+ type InlineSegment,
7
+ type MarkdownBlock,
8
+ type MarkdownDocument,
9
+ } from "./markdown";
10
+ import type { PanelStyles } from "./styles";
11
+
12
+ /**
13
+ * Renders the bounded Markdown subset from {@link parseMarkdown} with React
14
+ * Native primitives and theme tokens only.
15
+ *
16
+ * Links are shown as accent-coloured label plus visible target and are *not*
17
+ * pressable: the panel is read-only and must never hand an issue-authored URL to
18
+ * `Linking`. Malformed Markdown degrades to plain paragraphs upstream, so this
19
+ * component has no error path of its own.
20
+ */
21
+ export function MarkdownView({
22
+ styles,
23
+ theme,
24
+ source,
25
+ parsed,
26
+ }: {
27
+ readonly styles: PanelStyles;
28
+ readonly theme: PluginTheme;
29
+ readonly source?: string | null;
30
+ /** Pre-parsed document; when absent, `source` is parsed here. */
31
+ readonly parsed?: MarkdownDocument;
32
+ }) {
33
+ const md = parsed ?? parseMarkdown(source ?? null);
34
+ if (md.blocks.length === 0) return null;
35
+ return (
36
+ <View style={styles.markdownStack}>
37
+ {md.blocks.map((block, index) => (
38
+ <MarkdownBlockView key={index} styles={styles} theme={theme} block={block} />
39
+ ))}
40
+ {md.truncated ? <Text style={styles.markdownTruncated}>…truncated for display</Text> : null}
41
+ </View>
42
+ );
43
+ }
44
+
45
+ function MarkdownBlockView({
46
+ styles,
47
+ theme,
48
+ block,
49
+ }: {
50
+ readonly styles: PanelStyles;
51
+ readonly theme: PluginTheme;
52
+ readonly block: MarkdownBlock;
53
+ }) {
54
+ switch (block.kind) {
55
+ case "heading":
56
+ return (
57
+ <Text
58
+ accessibilityRole="header"
59
+ style={
60
+ block.level === 1
61
+ ? styles.markdownHeading1
62
+ : block.level === 2
63
+ ? styles.markdownHeading2
64
+ : styles.markdownHeading3
65
+ }
66
+ >
67
+ <Inline styles={styles} segments={block.inline} />
68
+ </Text>
69
+ );
70
+ case "paragraph":
71
+ return (
72
+ <Text style={styles.markdownParagraph}>
73
+ <Inline styles={styles} segments={block.inline} />
74
+ </Text>
75
+ );
76
+ case "listItem":
77
+ return (
78
+ <View style={[styles.markdownListRow, { paddingLeft: block.depth * 14 }]}>
79
+ {block.checked === null ? (
80
+ <Text style={styles.markdownListMarker}>{block.marker}</Text>
81
+ ) : (
82
+ <View style={styles.markdownTaskMark}>
83
+ <Icon name={block.checked ? "SquareCheck" : "Square"} size={13} color={block.checked ? theme.colors.statusSuccess : theme.colors.foregroundMuted} />
84
+ </View>
85
+ )}
86
+ <Text style={styles.markdownListText}>
87
+ <Inline styles={styles} segments={block.inline} />
88
+ </Text>
89
+ </View>
90
+ );
91
+ case "quote":
92
+ return (
93
+ <View style={styles.markdownQuote}>
94
+ <Text style={styles.markdownQuoteText}>
95
+ <Inline styles={styles} segments={block.inline} />
96
+ </Text>
97
+ </View>
98
+ );
99
+ case "rule":
100
+ return <View accessibilityRole="none" style={styles.markdownRule} />;
101
+ case "code":
102
+ return (
103
+ <View style={styles.markdownCodeBlock}>
104
+ {block.language === null ? null : (
105
+ <Text style={styles.markdownCodeLanguage}>{block.language}</Text>
106
+ )}
107
+ {block.lines.map((line, index) => (
108
+ <Text key={index} style={styles.markdownCodeText}>
109
+ {line.length === 0 ? " " : line}
110
+ </Text>
111
+ ))}
112
+ </View>
113
+ );
114
+ }
115
+ }
116
+
117
+ function Inline({
118
+ styles,
119
+ segments,
120
+ }: {
121
+ readonly styles: PanelStyles;
122
+ readonly segments: readonly InlineSegment[];
123
+ }) {
124
+ return (
125
+ <>
126
+ {segments.map((segment, index) => {
127
+ if (segment.code) {
128
+ return (
129
+ <Text key={index} style={styles.markdownInlineCode}>
130
+ {segment.text}
131
+ </Text>
132
+ );
133
+ }
134
+ const emphasis = [
135
+ segment.strong ? styles.markdownStrong : null,
136
+ segment.emphasis ? styles.markdownEmphasis : null,
137
+ ];
138
+ if (segment.href === null) {
139
+ return (
140
+ <Text key={index} style={emphasis}>
141
+ {segment.text}
142
+ </Text>
143
+ );
144
+ }
145
+ // The URL is shown rather than hidden behind the label: the panel never
146
+ // opens links, so the reader has to be able to read the target.
147
+ return (
148
+ <Text key={index} style={[styles.markdownLink, ...emphasis]}>
149
+ {segment.text}
150
+ <Text style={styles.markdownLinkTarget}>{` (${segment.href})`}</Text>
151
+ </Text>
152
+ );
153
+ })}
154
+ </>
155
+ );
156
+ }