@astrosheep/square 0.4.7 → 0.4.8

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (83) hide show
  1. package/dist/card-format.d.ts +2 -0
  2. package/dist/card-format.js +2 -0
  3. package/dist/claude-mcp-context.d.ts +0 -1
  4. package/dist/claude-mcp-context.js +0 -10
  5. package/dist/claude-mod.d.ts +2 -0
  6. package/dist/claude-mod.js +211 -0
  7. package/dist/cli/context.d.ts +1 -1
  8. package/dist/cli/context.js +1 -12
  9. package/dist/cli/express-presentation.js +6 -33
  10. package/dist/cli/json.js +1 -1
  11. package/dist/cli/observation-commands.d.ts +2 -3
  12. package/dist/cli/observation-commands.js +21 -59
  13. package/dist/cli/registry.js +3 -5
  14. package/dist/cli/square-commands.d.ts +1 -2
  15. package/dist/cli/square-commands.js +7 -17
  16. package/dist/decisions.js +5 -4
  17. package/dist/harness-claude.js +3 -3
  18. package/dist/help.js +6 -8
  19. package/dist/mcp.js +1 -1
  20. package/dist/neutral.d.ts +15 -1
  21. package/dist/notifications.js +1 -1
  22. package/dist/operation-presentation.d.ts +17 -15
  23. package/dist/operation-presentation.js +58 -32
  24. package/dist/packages/agent-delivery/src/index.d.ts +4 -9
  25. package/dist/packages/agent-delivery/src/index.js +0 -9
  26. package/dist/packages/agent-delivery/src/local-endpoint.d.ts +2 -12
  27. package/dist/packages/agent-delivery/src/local-endpoint.js +4 -48
  28. package/dist/packages/agent-delivery/src/pi-client.js +0 -2
  29. package/dist/packages/agent-delivery/src/types.d.ts +3 -37
  30. package/dist/plugins/paseo/shared/card-format.d.ts +6 -0
  31. package/dist/plugins/paseo/shared/card-format.js +38 -0
  32. package/dist/plugins/paseo/shared/card-presentation.d.ts +3 -1
  33. package/dist/plugins/paseo/shared/card-presentation.js +3 -1
  34. package/dist/presentation.d.ts +2 -20
  35. package/dist/presentation.js +11 -46
  36. package/dist/sqlite-square.js +5 -6
  37. package/dist/square-facade.d.ts +3 -12
  38. package/dist/square-posting.js +1 -1
  39. package/dist/square-tools.d.ts +0 -11
  40. package/dist/square-tools.js +33 -119
  41. package/dist/square-values.d.ts +1 -1
  42. package/dist/square-values.js +1 -1
  43. package/dist/time.d.ts +1 -0
  44. package/dist/time.js +10 -0
  45. package/dist/views.d.ts +1 -1
  46. package/dist/views.js +2 -2
  47. package/dist/wake-transports.d.ts +1 -1
  48. package/dist/wake-transports.js +1 -1
  49. package/guides/catch-cold-path-optimization.md +1 -1
  50. package/guides/claude-native.md +37 -7
  51. package/guides/delivery-primitives.md +2 -1
  52. package/guides/mcp.md +3 -3
  53. package/guides/pi-native-delivery.md +10 -2
  54. package/guides/pi-tools.md +1 -3
  55. package/guides/row-storage-design.md +1 -1
  56. package/package.json +1 -1
  57. package/plugins/claude/.claude-plugin/plugin.json +3 -2
  58. package/plugins/claude/README.md +9 -0
  59. package/plugins/claude/hooks/hooks.json +1 -9
  60. package/plugins/claude/hooks/register.js +178 -0
  61. package/plugins/claude/skills/square/SKILL.md +2 -2
  62. package/plugins/codex/.codex-plugin/plugin.json +1 -1
  63. package/plugins/paseo/OVERVIEW.md +1 -1
  64. package/plugins/paseo/README.md +20 -23
  65. package/plugins/paseo/client/markdown.tsx +88 -95
  66. package/plugins/paseo/client/palette.ts +37 -12
  67. package/plugins/paseo/client/square-message.tsx +52 -76
  68. package/plugins/paseo/client/transform.ts +4 -6
  69. package/plugins/paseo/shared/card-format.ts +37 -0
  70. package/plugins/paseo/shared/card-presentation.ts +6 -2
  71. package/plugins/paseo/shared/message.ts +1 -6
  72. package/plugins/pi/square-pi.js +69 -50
  73. package/skills/square/SKILL.md +2 -2
  74. package/dist/claude-boundary-state.d.ts +0 -20
  75. package/dist/claude-boundary-state.js +0 -84
  76. package/dist/claude-hook.d.ts +0 -2
  77. package/dist/claude-hook.js +0 -232
  78. package/dist/packages/agent-delivery/src/claude-auth.d.ts +0 -13
  79. package/dist/packages/agent-delivery/src/claude-auth.js +0 -75
  80. package/dist/packages/agent-delivery/src/claude-native.d.ts +0 -30
  81. package/dist/packages/agent-delivery/src/claude-native.js +0 -75
  82. package/dist/packages/agent-delivery/src/claude.d.ts +0 -5
  83. package/dist/packages/agent-delivery/src/claude.js +0 -66
@@ -1,30 +1,41 @@
1
1
  import { useMemo, type ReactNode } from "react";
2
- import { Platform, Text, View, type TextStyle } from "react-native";
3
- import type { PluginTheme } from "@getpaseo/plugin";
2
+ import { Platform, ScrollView, Text, View, type TextStyle } from "react-native";
3
+ import { withAlpha, type SquarePalette } from "./palette";
4
4
  import { Lexer, lexer, type Token, type Tokens } from "marked";
5
5
 
6
6
  export const MONO = Platform.select({ ios: "Menlo", android: "monospace", default: "ui-monospace, Menlo, monospace" });
7
7
 
8
- function makeStyles(colors: PluginTheme["colors"]) {
8
+ function makeStyles(palette: SquarePalette) {
9
+ const colors = palette.theme.colors;
10
+ const hairline = { borderColor: colors.border, borderWidth: 0.5 };
9
11
  return {
10
- block: { gap: 6 },
11
- text: { color: colors.foreground, fontSize: 13, lineHeight: 19 },
12
- lead: { color: colors.accent, fontWeight: "600" as const },
12
+ block: { gap: 8 },
13
+ text: { color: palette.body, fontSize: 13.5, lineHeight: 20 },
14
+ // Size carries only the top two levels; deeper headings are weight alone, so a card never shouts.
15
+ heading: { fontWeight: "600" as const, color: colors.foreground, letterSpacing: -0.1 },
16
+ headingSizes: [{ fontSize: 15.5, lineHeight: 22 }, { fontSize: 14.5, lineHeight: 21 }] as TextStyle[],
17
+ lead: { color: palette.name, fontWeight: "600" as const },
13
18
  strong: { fontWeight: "600" as const, color: colors.foreground },
14
- em: { color: colors.foregroundMuted, fontStyle: "italic" as const },
19
+ em: { fontStyle: "italic" as const },
15
20
  del: { color: colors.foregroundMuted, textDecorationLine: "line-through" as const },
16
- link: { color: colors.accent, textDecorationLine: "underline" as const },
17
- inlineCode: { fontFamily: MONO },
18
- codeBlock: { backgroundColor: colors.surface0, borderRadius: 4, paddingHorizontal: 8, paddingVertical: 6 },
19
- codeText: { fontFamily: MONO, fontSize: 13, lineHeight: 19, color: colors.foreground },
20
- quote: { borderLeftWidth: 2, borderLeftColor: colors.border, paddingLeft: 8, gap: 2 },
21
- quoteText: { color: colors.foregroundMuted, fontStyle: "italic" as const },
22
- separator: { height: 1, backgroundColor: colors.border, marginVertical: 4 },
23
- listRow: { flexDirection: "row" as const, gap: 6 },
24
- listItem: { flexShrink: 1, gap: 2 },
25
- bullet: { color: colors.foregroundMuted, fontSize: 13, lineHeight: 19 },
26
- tableRow: { flexDirection: "row" as const, gap: 8 },
27
- tableCell: { flex: 1 },
21
+ link: { color: colors.accent, textDecorationLine: "underline" as const, textDecorationColor: withAlpha(colors.accent, 0.4) },
22
+ inlineCode: { fontFamily: MONO, fontSize: 12, color: colors.foreground, backgroundColor: palette.inlineCode, borderRadius: 3 },
23
+ codeBlock: { ...hairline, backgroundColor: colors.surface0, borderRadius: 4 },
24
+ codeContent: { paddingHorizontal: 10, paddingVertical: 8 },
25
+ codeText: { fontFamily: MONO, fontSize: 12, lineHeight: 18, color: colors.foreground },
26
+ quote: { borderLeftWidth: 2, borderLeftColor: colors.border, paddingLeft: 10, gap: 8 },
27
+ quoteText: { color: colors.foregroundMuted },
28
+ separator: { height: 0.5, backgroundColor: colors.border, marginVertical: 4 },
29
+ list: { gap: 4 },
30
+ listRow: { flexDirection: "row" as const },
31
+ // A fixed gutter gives wrapped lines a hanging indent; ordinals align on their right edge.
32
+ bullet: { color: colors.foregroundMuted, fontSize: 13.5, lineHeight: 20, minWidth: 18, paddingRight: 6,
33
+ textAlign: "right" as const, fontVariant: ["tabular-nums" as const] },
34
+ listItem: { flex: 1, gap: 4 },
35
+ table: { ...hairline, borderRadius: 4, overflow: "hidden" as const },
36
+ tableHead: { backgroundColor: colors.surface0 },
37
+ tableRow: { flexDirection: "row" as const, borderTopWidth: 0.5, borderTopColor: colors.border },
38
+ tableCell: { paddingVertical: 4, paddingHorizontal: 8 },
28
39
  };
29
40
  }
30
41
 
@@ -33,27 +44,27 @@ type Styles = ReturnType<typeof makeStyles>;
33
44
  /** Children of a token, if it has any: `Token` is a union, so the optional field needs one cast. */
34
45
  const children = (node: Token | { tokens?: Token[] }): Token[] => (node as { tokens?: Token[] }).tokens ?? [];
35
46
 
36
- const unescape = (text: string): string =>
37
- text.replace(/&(lt|gt|quot|#39|amp);/g, (_, entity: string) =>
38
- entity === "lt" ? "<" : entity === "gt" ? ">" : entity === "quot" ? '"' : entity === "#39" ? "'" : "&");
47
+ const ENTITIES: Record<string, string> = { lt: "<", gt: ">", quot: '"', "#39": "'", amp: "&" };
48
+ const unescape = (text: string): string => text.replace(/&(lt|gt|quot|#39|amp);/g, (_, entity: string) => ENTITIES[entity]!);
39
49
 
40
- function renderInlines(tokens: Token[], styles: Styles, prefix: string, extra?: TextStyle): ReactNode[] {
50
+ /** Inline runs nest inside their block's Text, so they carry only what differs from it. */
51
+ function renderInlines(tokens: Token[], styles: Styles, prefix: string): ReactNode[] {
41
52
  return tokens.map((token, index) => {
42
53
  const key = `${prefix}-${index}`;
43
- const nested = (style: TextStyle): ReactNode => (
44
- <Text key={key} style={[styles.text, extra, style]}>{renderInlines(children(token), styles, key)}</Text>
54
+ const nested = (style?: TextStyle): ReactNode => (
55
+ <Text key={key} style={style}>{renderInlines(children(token), styles, key)}</Text>
45
56
  );
46
57
  switch (token.type) {
47
58
  case "strong": return nested(styles.strong);
48
59
  case "em": return nested(styles.em);
49
60
  case "del": return nested(styles.del);
50
61
  case "link": return nested(styles.link);
51
- case "codespan": return <Text key={key} style={[styles.text, extra, styles.inlineCode]}>{token.text}</Text>;
62
+ case "codespan": return <Text key={key} style={styles.inlineCode}>{` ${unescape(token.text)} `}</Text>;
52
63
  case "br": return "\n";
53
64
  case "image": return token.text;
54
65
  default: {
55
66
  const inline = token as Tokens.Generic;
56
- if (inline.tokens !== undefined) return nested({});
67
+ if (inline.tokens !== undefined) return nested();
57
68
  return unescape(inline.text ?? inline.raw);
58
69
  }
59
70
  }
@@ -74,13 +85,13 @@ function renderBlocks(tokens: Token[], styles: Styles, prefix: string, textStyle
74
85
  return <View key={key} style={styles.separator} />;
75
86
  case "code":
76
87
  return (
77
- <View key={key} style={styles.codeBlock}>
88
+ <ScrollView key={key} horizontal style={styles.codeBlock} contentContainerStyle={styles.codeContent}>
78
89
  <Text selectable style={styles.codeText}>{token.text}</Text>
79
- </View>
90
+ </ScrollView>
80
91
  );
81
92
  case "heading":
82
93
  return (
83
- <Text key={key} style={[styles.text, textStyle, styles.strong]}>
94
+ <Text key={key} style={[styles.text, textStyle, styles.heading, styles.headingSizes[(token as Tokens.Heading).depth - 1]]}>
84
95
  {renderInlines(children(token), styles, key)}
85
96
  </Text>
86
97
  );
@@ -90,11 +101,11 @@ function renderBlocks(tokens: Token[], styles: Styles, prefix: string, textStyle
90
101
  const list = token as Tokens.List;
91
102
  const start = typeof list.start === "number" ? list.start : 1;
92
103
  return (
93
- <View key={key} style={{ gap: 2 }}>
104
+ <View key={key} style={styles.list}>
94
105
  {list.items.map((item, itemIndex) => (
95
106
  <View key={`${key}-${itemIndex}`} style={styles.listRow}>
96
107
  <Text style={styles.bullet}>
97
- {item.task ? (item.checked === true ? "☑" : "☐") : list.ordered ? `${start + itemIndex}.` : "·"}
108
+ {item.task ? (item.checked === true ? "☑" : "☐") : list.ordered ? `${start + itemIndex}.` : "•"}
98
109
  </Text>
99
110
  <View style={styles.listItem}>{renderBlocks(children(item), styles, `${key}-${itemIndex}`, textStyle)}</View>
100
111
  </View>
@@ -105,19 +116,26 @@ function renderBlocks(tokens: Token[], styles: Styles, prefix: string, textStyle
105
116
  case "table": {
106
117
  const table = token as Tokens.Table;
107
118
  const rows = [table.header, ...table.rows];
119
+ // Columns size to their widest cell (within reason) and the table scrolls sideways
120
+ // on narrow screens instead of crushing every cell to a sliver.
121
+ const widths = table.header.map((_, column) => Math.min(240, Math.max(56,
122
+ ...rows.map(cells => (cells[column]?.text.length ?? 0) * 7.5 + 16))));
108
123
  return (
109
- <View key={key} style={{ gap: 2 }}>
110
- {rows.map((cells, rowIndex) => (
111
- <View key={`${key}-${rowIndex}`} style={styles.tableRow}>
112
- {cells.map((cell, cellIndex) => (
113
- <Text
114
- key={`${key}-${rowIndex}-${cellIndex}`}
115
- style={[styles.text, textStyle, styles.tableCell, rowIndex === 0 ? styles.strong : undefined]}
116
- >{renderInlines(children(cell), styles, `${key}-${rowIndex}-${cellIndex}`)}</Text>
117
- ))}
118
- </View>
119
- ))}
120
- </View>
124
+ <ScrollView key={key} horizontal>
125
+ <View style={styles.table}>
126
+ {rows.map((cells, rowIndex) => (
127
+ <View key={`${key}-${rowIndex}`} style={rowIndex === 0 ? [styles.tableRow, styles.tableHead, { borderTopWidth: 0 }] : styles.tableRow}>
128
+ {cells.map((cell, cellIndex) => (
129
+ <Text
130
+ key={`${key}-${rowIndex}-${cellIndex}`}
131
+ style={[styles.text, textStyle, styles.tableCell, { width: widths[cellIndex], textAlign: table.align[cellIndex] ?? "left" },
132
+ rowIndex === 0 ? styles.strong : undefined]}
133
+ >{renderInlines(children(cell), styles, `${key}-${rowIndex}-${cellIndex}`)}</Text>
134
+ ))}
135
+ </View>
136
+ ))}
137
+ </View>
138
+ </ScrollView>
121
139
  );
122
140
  }
123
141
  default: {
@@ -132,58 +150,33 @@ function renderBlocks(tokens: Token[], styles: Styles, prefix: string, textStyle
132
150
  });
133
151
  }
134
152
 
135
- /** A chorus speaker in seal blue, with a fixed half-em breath before the words. */
136
- function Lead({ name, styles }: { name: string; styles: Styles }) {
137
- return <Text style={styles.lead}>{name}{"\u2002"}</Text>;
138
- }
139
-
140
- /** One line (or a few) of Markdown, inline only: the collapsed preview on a notification card. */
141
- export function SquareInline({ text, theme, numberOfLines, lead }: {
153
+ /** Attribution stays inline; a separate timestamp column reserves space on the first render. */
154
+ export function SquareMarkdownBody({ text, palette, author, reach, time }: {
142
155
  text: string;
143
- theme: PluginTheme;
144
- numberOfLines?: number;
145
- lead?: string;
156
+ palette: SquarePalette;
157
+ author?: string;
158
+ reach?: string;
159
+ time?: string;
146
160
  }) {
147
- const styles = useMemo(() => makeStyles(theme.colors), [theme.colors]);
148
- return (
149
- <Text numberOfLines={numberOfLines} style={styles.text}>
150
- {lead !== undefined && <Lead name={lead} styles={styles} />}
151
- {renderInlines(Lexer.lexInline(text), styles, "inline")}
152
- </Text>
153
- );
154
- }
155
-
156
- /**
157
- * A body of Markdown: no envelope — the words only, at full width.
158
- * `lead` runs the author into the first line.
159
- */
160
- export function SquareMarkdownBody({ text, theme, lead }: {
161
- text: string;
162
- theme: PluginTheme;
163
- lead?: string;
164
- }) {
165
- const styles = useMemo(() => makeStyles(theme.colors), [theme.colors]);
161
+ const styles = useMemo(() => makeStyles(palette), [palette]);
166
162
  const tokens = useMemo(() => lexer(text).filter(token => token.type !== "space"), [text]);
167
- let pending = lead;
168
- const nodes = tokens.map((token, index) => {
169
- const key = `body-${index}`;
170
- if (pending === undefined) return renderBlocks([token], styles, key);
171
- const name = pending;
172
- pending = undefined;
173
- if (token.type === "paragraph") {
174
- return (
175
- <Text key={key} selectable={Platform.OS === "web"} style={styles.text}>
176
- <Lead name={name} styles={styles} />
177
- {renderInlines(children(token), styles, key)}
178
- </Text>
179
- );
180
- }
181
- return [<Text key={`${key}-lead`} style={styles.text}><Lead name={name} styles={styles} /></Text>, ...renderBlocks([token], styles, key)];
182
- });
183
- return (
184
- <View style={styles.block}>
185
- {nodes}
186
- {pending !== undefined && <Text style={styles.text}><Lead name={pending} styles={styles} /></Text>}
187
- </View>
188
- );
163
+ const attribution = <>
164
+ {author !== undefined && <Text style={styles.lead}>{author}</Text>}
165
+ {reach !== undefined && <Text style={{ fontSize: 11, fontWeight: "500", color: palette.warm }}>{" "}{reach}</Text>}
166
+ {(author !== undefined || reach !== undefined) && "\u2002"}
167
+ </>;
168
+ const timeStyle: TextStyle = { color: palette.faint, fontSize: 11, lineHeight: 20, fontVariant: ["tabular-nums"] };
169
+ const first = tokens[0];
170
+ const runIn = first?.type === "paragraph" || first?.type === "heading";
171
+ const hasHeader = author !== undefined || reach !== undefined || time !== undefined;
172
+ const firstLine = <>{attribution}{runIn && renderInlines(children(first), styles, "first")}</>;
173
+ return <View style={styles.block}>
174
+ {hasHeader && <View style={{ flexDirection: "row", alignItems: "flex-start", gap: 12 }}>
175
+ <Text selectable={Platform.OS === "web"} style={[styles.text, { flex: 1, minWidth: 0 }]}>
176
+ {firstLine}
177
+ </Text>
178
+ {time !== undefined && <Text style={[timeStyle, { flexShrink: 0 }]}>{time}</Text>}
179
+ </View>}
180
+ {renderBlocks(hasHeader && runIn ? tokens.slice(1) : tokens, styles, "body")}
181
+ </View>;
189
182
  }
@@ -7,22 +7,22 @@ import type { PluginTheme } from "@getpaseo/plugin";
7
7
  * the card earns its identity from colour and the seal, not decoration.
8
8
  */
9
9
  const DARK = {
10
- surface0: "#14161a", // inset / code
10
+ surface0: "#1a1d21", // inset / code
11
11
  surface1: "#252a2e", // card (matte slate paper)
12
- surface2: "#14161a", // inset / code
13
- border: "#3a4249",
14
- foreground: "#d9dce1",
12
+ surface2: "#1a1d21", // inset / code
13
+ border: "#363d44",
14
+ foreground: "#e4e6ea",
15
15
  foregroundMuted: "#858b94",
16
16
  accent: "#94a6b5", // plaza ■ / links
17
17
  } as const;
18
18
 
19
19
  const LIGHT = {
20
- surface0: "#f4f5f7",
21
- surface1: "#edf0f2", // card (matte slate paper)
22
- surface2: "#f4f5f7",
23
- border: "#ced5db",
24
- foreground: "#2b2e33",
25
- foregroundMuted: "#858b94",
20
+ surface0: "#e9edf0",
21
+ surface1: "#f6f7f9", // card (matte slate paper)
22
+ surface2: "#e9edf0",
23
+ border: "#dde2e7",
24
+ foreground: "#1f2226",
25
+ foregroundMuted: "#6b727b",
26
26
  accent: "#71869a",
27
27
  } as const;
28
28
 
@@ -33,7 +33,14 @@ export interface SquarePalette {
33
33
  card: { borderRadius: number; borderWidth: number; boxShadow: string };
34
34
  /** The square ■ seal, unrounded and flat. */
35
35
  seal: { borderRadius: number };
36
- /** The thin quote guide under the header. */
36
+ /** Body and metadata tones derived from the slate theme. */
37
+ body: string;
38
+ faint: string;
39
+ name: string;
40
+ warm: string;
41
+ directGuide: string;
42
+ inlineCode: string;
43
+ /** The thin message guide under the header. */
37
44
  quote: { borderLeftWidth: number; borderLeftColor: string; paddingLeft: number };
38
45
  }
39
46
 
@@ -45,6 +52,18 @@ function brightness(hex: string): number {
45
52
  + 0.114 * parseInt(rgb.slice(4, 6), 16);
46
53
  }
47
54
 
55
+ export function withAlpha(hex: string, opacity: number): string {
56
+ const rgb = hex.length === 4 ? "#" + hex.slice(1).replace(/./g, digit => digit + digit) : hex.slice(0, 7);
57
+ return `${rgb}${Math.round(opacity * 255).toString(16).padStart(2, "0")}`;
58
+ }
59
+
60
+ function mix(first: string, second: string, amount: number): string {
61
+ const rgb = (hex: string) => (hex.length === 4 ? hex.slice(1).replace(/./g, digit => digit + digit) : hex.slice(1, 7));
62
+ const a = rgb(first), b = rgb(second);
63
+ return "#" + [0, 2, 4].map(offset => Math.round(parseInt(a.slice(offset, offset + 2), 16) * amount
64
+ + parseInt(b.slice(offset, offset + 2), 16) * (1 - amount)).toString(16).padStart(2, "0")).join("");
65
+ }
66
+
48
67
  export function squarePalette(theme: PluginTheme): SquarePalette {
49
68
  // The SDK exposes colors, not appearance. Use the host theme, never the OS color scheme.
50
69
  const dark = brightness(theme.colors.foreground) > brightness(theme.colors.surface0);
@@ -59,6 +78,12 @@ export function squarePalette(theme: PluginTheme): SquarePalette {
59
78
  } },
60
79
  card: { borderRadius: 4, borderWidth: 0.5, boxShadow: "none" },
61
80
  seal: { borderRadius: 0 },
62
- quote: { borderLeftWidth: 1, borderLeftColor: dark ? "#45535e" : "#c2cdd5", paddingLeft: 9 },
81
+ body: mix(palette.foreground, palette.surface1, 0.9),
82
+ faint: mix(palette.foregroundMuted, palette.surface1, 0.7),
83
+ name: mix(palette.accent, palette.foreground, 0.8),
84
+ warm: theme.colors.statusDanger,
85
+ directGuide: withAlpha(theme.colors.statusDanger, 0.9),
86
+ inlineCode: withAlpha(palette.accent, 0.1),
87
+ quote: { borderLeftWidth: 1, borderLeftColor: withAlpha(theme.colors.statusWarning, 0.45), paddingLeft: 10 },
63
88
  };
64
89
  }
@@ -1,88 +1,64 @@
1
1
  import type { PluginTimelineItemProps } from "@getpaseo/plugin/client";
2
2
  import { Icon } from "@getpaseo/plugin/client/react-native";
3
- import { useMemo, useState } from "react";
3
+ import { useMemo, useRef, useState } from "react";
4
4
  import { Pressable, ScrollView, Text, View } from "react-native";
5
- import { COLLAPSED_PREVIEW_MAX_CHARS, outputPreview, type SquareMessageData } from "../shared/message";
6
- import { MONO, SquareInline, SquareMarkdownBody } from "./markdown";
5
+ import { cardReferences, reachLabel, timeLabel } from "../shared/card-format";
6
+ import type { SquareMessageData } from "../shared/message";
7
+ import { SquareMarkdownBody } from "./markdown";
7
8
  import { squarePalette } from "./palette";
8
9
 
9
- /**
10
- * A matte slate slip in the timeline's shared 4px-corner family: square seal ■,
11
- * thin quote guide under the header.
12
- * Header (both states): ■ origin · authors … refs · how many.
13
- * Collapsed: two preview lines. Expanded: the whole scene, at full width.
14
- * Each structured message keeps its own author, reference and body.
15
- */
10
+ /** The first guide reaches 4px above its text, toward the seal; the collapsed window still shows 4 lines. */
11
+ const GUIDE_LEAD = 4;
12
+ const COLLAPSED_HEIGHT = 4 * 20 + GUIDE_LEAD;
13
+
16
14
  export function SquareMessage({ item, theme, layout }: PluginTimelineItemProps<SquareMessageData>) {
17
15
  const [expanded, setExpanded] = useState(false);
18
16
  const [hovered, setHovered] = useState(false);
17
+ const [overflows, setOverflows] = useState(false);
18
+ const scroll = useRef<ScrollView>(null);
19
19
  const palette = useMemo(() => squarePalette(theme), [theme]);
20
- const items = "items" in item.data ? item.data.items : undefined;
21
- const text = "text" in item.data ? item.data.text : undefined;
22
- const presentations = items?.filter(entry => "origin" in entry);
23
- const latest = items?.at(-1);
24
- // Keep the source's own line breaks: collapsed and expanded text must wrap alike.
25
- const preview = latest && "text" in latest ? outputPreview(latest.text, COLLAPSED_PREVIEW_MAX_CHARS)
26
- : latest?.preview ?? text;
27
- const authors = [...new Set(presentations?.flatMap(entry => entry.author === undefined ? [] : [entry.author]))];
28
- const chorus = authors.length > 1;
29
- const origins = [...new Set(presentations?.map(entry => entry.origin))];
30
- const originName = origins.join(", ") || "Notification";
31
- const badge = presentations?.at(-1)?.badge;
32
- // Exact references never imply intervening messages; each origin keeps its own references.
33
- const trailing = [presentations?.flatMap(entry => entry.ref === undefined ? []
34
- : [origins.length > 1 ? `${entry.origin} ${entry.ref}` : entry.ref]).join(", "),
35
- presentations && presentations.length > 1 ? String(presentations.length) : undefined].filter(Boolean).join(" · ");
36
- const { accent, border, foreground, foregroundMuted: muted, surface1 } = palette.theme.colors;
37
- // On web the header brightens its details under the cursor; touch keeps them quiet.
38
- const detail = layout.platform === "web" && hovered ? foreground : muted;
39
- const row = { flexDirection: "row" as const, alignItems: "center" as const, gap: 6 };
40
- const guide = { marginLeft: 2.5, paddingLeft: palette.quote.paddingLeft, borderLeftWidth: palette.quote.borderLeftWidth, borderLeftColor: palette.quote.borderLeftColor };
20
+ const entries = "items" in item.data ? item.data.items : [{ text: item.data.text }];
21
+ const presentations = entries.filter(entry => "origin" in entry);
22
+ const originName = [...new Set(presentations.map(entry => entry.origin))].join(", ") || "Notification";
23
+ const refs = cardReferences(presentations);
24
+ const { accent, border, foregroundMuted: muted, surface1 } = palette.theme.colors;
25
+ const detail = layout.platform === "web" && hovered ? muted : palette.faint;
41
26
 
42
- return (
43
- <View style={[
44
- { backgroundColor: surface1, borderWidth: palette.card.borderWidth, borderColor: border, borderRadius: palette.card.borderRadius, paddingHorizontal: 10, paddingVertical: 7 },
45
- layout.platform === "web" && { boxShadow: palette.card.boxShadow },
46
- ]}>
47
- <Pressable
48
- accessibilityRole="button"
49
- accessibilityLabel={`${expanded ? "收起" : "展开"} ${originName}: ${authors.join(", ") || "Notification"}`}
50
- aria-expanded={expanded}
51
- onPress={() => setExpanded(value => !value)}
52
- onHoverIn={() => setHovered(true)}
53
- onHoverOut={() => setHovered(false)}
54
- style={({ pressed }) => ({ opacity: pressed ? 0.85 : 1 })}
55
- >
56
- <View style={row}>
57
- <View style={{ width: 6, height: 6, borderRadius: palette.seal.borderRadius, backgroundColor: accent }} />
58
- <Text numberOfLines={1} style={{ color: foreground, fontSize: 11.5, lineHeight: 17, fontWeight: "600", flex: 1 }}>
59
- {originName}
60
- {(authors.length > 0 || badge) && (
61
- <Text style={{ color: muted, fontWeight: "400", fontSize: 10.5 }}> · {[authors.join(", "), badge].filter(Boolean).join(" · ")}</Text>
62
- )}
63
- </Text>
64
- {trailing !== "" && <Text numberOfLines={1} style={{ color: detail, fontSize: 9.5, fontFamily: MONO, fontVariant: ["tabular-nums"], flexShrink: 1 }}>{trailing}</Text>}
65
- <Icon name={expanded ? "ChevronUp" : "ChevronDown"} size={10} color={detail} />
66
- </View>
67
- {!expanded && preview !== undefined && preview !== "" && (
68
- <View style={{ ...guide, marginTop: 5 }}>
69
- <SquareInline text={preview} theme={palette.theme} numberOfLines={2} lead={chorus && latest && "author" in latest ? latest.author : undefined} />
70
- </View>
71
- )}
72
- </Pressable>
73
- {expanded && (
74
- <ScrollView style={{ maxHeight: layout.compact ? 280 : 400, marginTop: 5 }} nestedScrollEnabled>
75
- <View style={{ ...guide, gap: 7 }}>
76
- {items === undefined ? <SquareMarkdownBody text={text ?? ""} theme={palette.theme} /> : items.map((entry, index) => (
77
- <View key={index} style={index > 0 ? { paddingTop: 7, borderTopWidth: palette.card.borderWidth, borderTopColor: border } : undefined}>
78
- {"text" in entry
79
- ? <SquareMarkdownBody text={entry.text} theme={palette.theme} />
80
- : <SquareMarkdownBody text={entry.body} theme={palette.theme} lead={chorus ? entry.author : undefined} />}
81
- </View>
82
- ))}
83
- </View>
84
- </ScrollView>
85
- )}
27
+ return <Pressable onPress={() => {
28
+ scroll.current?.scrollTo({ y: 0, animated: false });
29
+ setExpanded(value => !value);
30
+ }} accessibilityRole="button"
31
+ accessibilityLabel={`${expanded ? "Collapse" : "Expand"} ${originName}`} aria-expanded={expanded}
32
+ onHoverIn={() => setHovered(true)} onHoverOut={() => setHovered(false)}
33
+ style={{ backgroundColor: surface1, borderWidth: palette.card.borderWidth,
34
+ borderColor: border, borderRadius: palette.card.borderRadius, paddingVertical: 9, paddingHorizontal: 12 }}>
35
+ <View style={{ flexDirection: "row", alignItems: "center", gap: 7, height: 16 }}>
36
+ <View style={{ width: 5, height: 5, backgroundColor: accent }} />
37
+ <Text numberOfLines={1} style={{ color: muted, fontSize: 11, fontWeight: "500", flexShrink: 1 }}>{originName}</Text>
38
+ {entries.length > 1 && <Text style={{ color: detail, fontSize: 11 }}>{entries.length} messages</Text>}
39
+ <View style={{ flex: 1 }} />
40
+ {!!refs && <Text numberOfLines={1} style={{ color: detail, fontSize: 11, fontVariant: ["tabular-nums"], flexShrink: 1 }}>{refs}</Text>}
41
+ <View style={{ width: 10, height: 10 }}>
42
+ {overflows && <Icon name={expanded ? "ChevronUp" : "ChevronDown"} size={10} color={detail} />}
43
+ </View>
44
+ </View>
45
+ <View style={{ marginTop: 8 - GUIDE_LEAD, marginLeft: 2 }}>
46
+ <ScrollView ref={scroll} style={{ maxHeight: expanded ? 260 : COLLAPSED_HEIGHT }} scrollEnabled={expanded} nestedScrollEnabled
47
+ onContentSizeChange={(_, height) => setOverflows(height > COLLAPSED_HEIGHT)}>
48
+ {entries.map((entry, index) => {
49
+ const structured = "origin" in entry;
50
+ // A mention speaks through the guide colour alone; reply and bell keep their word.
51
+ const direct = structured && reachLabel(entry.reach) !== undefined;
52
+ const reach = structured && entry.reach !== "mention" ? reachLabel(entry.reach) : undefined;
53
+ return <View key={index} style={{ borderLeftWidth: 1,
54
+ borderLeftColor: direct ? palette.directGuide : palette.quote.borderLeftColor,
55
+ paddingLeft: palette.quote.paddingLeft, marginTop: index > 0 ? 8 : 0, paddingTop: index > 0 ? 0 : GUIDE_LEAD }}>
56
+ <SquareMarkdownBody text={structured ? entry.body : entry.text} palette={palette}
57
+ author={structured ? entry.author : undefined} reach={reach}
58
+ time={structured && entry.at !== undefined ? timeLabel(entry.at) : undefined} />
59
+ </View>;
60
+ })}
61
+ </ScrollView>
86
62
  </View>
87
- );
63
+ </Pressable>;
88
64
  }
@@ -1,5 +1,5 @@
1
1
  import type { PluginTimelineTransformerContribution } from "@getpaseo/plugin/client";
2
- import { COLLAPSED_PREVIEW_MAX_CHARS, OUTPUT_DISPLAY_MAX_CHARS, outputPreview, squareCardDetailsSchema, type SquareMessageData } from "../shared/message.ts";
2
+ import { OUTPUT_DISPLAY_MAX_CHARS, outputPreview, squareCardDetailsSchema, type SquareMessageData } from "../shared/message.ts";
3
3
 
4
4
  // Recognition is metadata-based, not a guess from a tool name or body text.
5
5
  // The same projection applies to live events and fetched history.
@@ -12,17 +12,15 @@ export const transformSquareMessage: PluginTimelineTransformerContribution<"tool
12
12
  const details = squareCardDetailsSchema.safeParse(item.metadata.details);
13
13
  let data: SquareMessageData;
14
14
  if (details.success) {
15
- const items = details.data.items, latest = items.at(-1)!;
16
- const preview = outputPreview("text" in latest ? latest.text : latest.preview ?? latest.body, COLLAPSED_PREVIEW_MAX_CHARS);
17
- let remaining = OUTPUT_DISPLAY_MAX_CHARS - preview.length;
15
+ const items = details.data.items;
16
+ let remaining = OUTPUT_DISPLAY_MAX_CHARS;
18
17
  const clipped = items.reduce((sum, entry) => sum + ("text" in entry ? entry.text : entry.body).length, 0) > remaining;
19
18
  data = { items: items.map((entry, index) => {
20
19
  const source = "text" in entry ? entry.text : entry.body;
21
20
  const body = clipped ? outputPreview(source, Math.floor(remaining / (items.length - index))) : source;
22
21
  remaining -= body.length;
23
22
  if ("text" in entry) return { text: body };
24
- const { preview: _preview, ...presentation } = entry;
25
- return { ...presentation, body, ...(index === items.length - 1 ? { preview } : {}) };
23
+ return { ...entry, body };
26
24
  }) };
27
25
  } else {
28
26
  data = { text: outputPreview(item.detail.text) };
@@ -0,0 +1,37 @@
1
+ import type { CardPresentation } from "./card-presentation.ts";
2
+
3
+ export function reachLabel(reach?: CardPresentation["reach"]): string | undefined {
4
+ return reach === "mention" ? "@you" : reach === "attention" ? undefined : reach;
5
+ }
6
+
7
+ /** Calendar days, rather than elapsed hours, keep yesterday correct across DST. */
8
+ export function timeLabel(at: number, now = Date.now(), compact = false): string {
9
+ const date = new Date(at), today = new Date(now);
10
+ const day = (value: Date) => `${value.getFullYear()}-${value.getMonth()}-${value.getDate()}`;
11
+ const clock = `${String(date.getHours()).padStart(2, "0")}:${String(date.getMinutes()).padStart(2, "0")}`;
12
+ if (day(date) === day(today)) return clock;
13
+ const yesterday = new Date(today);
14
+ yesterday.setDate(today.getDate() - 1);
15
+ if (day(date) === day(yesterday)) return `${compact ? "yest" : "yesterday"} ${clock}`;
16
+ const shortDate = compact
17
+ ? `${String(date.getMonth() + 1).padStart(2, "0")}/${String(date.getDate()).padStart(2, "0")}`
18
+ : date.toLocaleDateString("en-US", { month: "short", day: "numeric" });
19
+ return `${shortDate} ${clock}`;
20
+ }
21
+
22
+ /** Only adjacent references from the same origin can form a range. */
23
+ export function cardReferences(items: readonly CardPresentation[], originLabel: (origin: string) => string = origin => origin): string {
24
+ const multipleOrigins = new Set(items.map(item => item.origin)).size > 1;
25
+ const runs: { origin: string; ref: string; first?: number; last?: number }[] = [];
26
+ for (const item of items) {
27
+ if (item.ref === undefined) continue;
28
+ const match = /^act\/(\d+)$/.exec(item.ref);
29
+ const index = match ? Number(match[1]) : undefined;
30
+ const previous = runs.at(-1);
31
+ if (index !== undefined && previous?.origin === item.origin && previous.last !== undefined && previous.last + 1 === index) {
32
+ previous.last = index;
33
+ } else runs.push({ origin: item.origin, ref: item.ref, first: index, last: index });
34
+ }
35
+ return runs.map(run => `${multipleOrigins ? `${originLabel(run.origin)} ` : ""}${run.first !== run.last
36
+ ? `act/${run.first}–${run.last}` : run.ref}`).join(", ");
37
+ }
@@ -4,7 +4,9 @@ export type CardPresentation = {
4
4
  readonly origin: string;
5
5
  readonly author?: string;
6
6
  readonly ref?: string;
7
- readonly badge?: string;
7
+ /** Absent on cards produced before landing time and reach were recorded. */
8
+ readonly at?: number;
9
+ readonly reach?: 'mention' | 'reply' | 'attention' | 'bell';
8
10
  readonly body: string;
9
11
  };
10
12
 
@@ -12,5 +14,7 @@ export function isCardPresentation(value: unknown): value is CardPresentation {
12
14
  if (!value || typeof value !== 'object' || Array.isArray(value)) return false;
13
15
  const item = value as Record<string, unknown>;
14
16
  return item.kind === 'presentation' && typeof item.origin === 'string' && typeof item.body === 'string'
15
- && ['author', 'ref', 'badge'].every(key => item[key] === undefined || typeof item[key] === 'string');
17
+ && (item.at === undefined || (typeof item.at === 'number' && Number.isFinite(item.at)))
18
+ && (item.reach === undefined || ['mention', 'reply', 'attention', 'bell'].includes(item.reach as string))
19
+ && ['author', 'ref'].every(key => item[key] === undefined || typeof item[key] === 'string');
16
20
  }
@@ -3,12 +3,10 @@ import { isCardPresentation, type CardPresentation } from "./card-presentation.t
3
3
 
4
4
  /** Aggregate client display budget, including omission lines; source history stays unchanged. */
5
5
  export const OUTPUT_DISPLAY_MAX_CHARS = 32_000;
6
- export const COLLAPSED_PREVIEW_MAX_CHARS = 1_000;
7
6
  export function outputPreview(text: string, maxChars = OUTPUT_DISPLAY_MAX_CHARS): string {
8
7
  if (maxChars <= 0) return "";
9
8
  if (text.length <= maxChars) return text;
10
9
  const middle = "\n\n… Showing the beginning and end; the middle is hidden in this preview. …\n\n";
11
- // Even a small share of a multi-message budget must fit, without splitting a surrogate pair.
12
10
  if (maxChars <= middle.length) return text.slice(0, maxChars - 1).replace(/[\uD800-\uDBFF]$/, "") + "…";
13
11
  const headChars = Math.floor((maxChars - middle.length) / 2);
14
12
  const head = text.slice(0, headChars).replace(/[\uD800-\uDBFF]$/, "");
@@ -16,13 +14,10 @@ export function outputPreview(text: string, maxChars = OUTPUT_DISPLAY_MAX_CHARS)
16
14
  return head + middle + tail;
17
15
  }
18
16
 
19
- /** Receiver display facts, with a bounded preview derived on the client. */
20
- const presentationItemSchema = z.custom<CardPresentation>(isCardPresentation)
21
- .and(z.object({ preview: z.string().optional() }));
17
+ const presentationItemSchema = z.custom<CardPresentation>(isCardPresentation);
22
18
  export const squareCardDetailsSchema = z.object({
23
19
  items: z.array(z.union([presentationItemSchema, z.object({ text: z.string() })])).min(1),
24
20
  });
25
-
26
21
  export const squareMessageSchema = z.union([
27
22
  squareCardDetailsSchema,
28
23
  z.object({ text: z.string() }),