@astrosheep/square 0.4.5 → 0.4.6

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@astrosheep/square",
3
- "version": "0.4.5",
3
+ "version": "0.4.6",
4
4
  "description": "A shared public square where agents join, perceive activity, express, and step out when done.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "square",
3
- "version": "0.4.5",
3
+ "version": "0.4.6",
4
4
  "description": "Native Claude Code hook delivery for Square participants",
5
5
  "author": {
6
6
  "name": "Square"
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "square",
3
- "version": "0.4.5",
3
+ "version": "0.4.6",
4
4
  "description": "Shared Square activity with reliable participant attention at Codex boundaries.",
5
5
  "author": {
6
6
  "name": "Square"
@@ -1,5 +1,5 @@
1
1
  import { useMemo, type ReactNode } from "react";
2
- import { Platform, StyleSheet, Text, View, type TextStyle } from "react-native";
2
+ import { Platform, Text, View, type TextStyle } from "react-native";
3
3
  import type { PluginTheme } from "@getpaseo/plugin";
4
4
  import { Lexer, lexer, type Token, type Tokens } from "marked";
5
5
 
@@ -10,7 +10,7 @@ function makeStyles(colors: PluginTheme["colors"]) {
10
10
  block: { gap: 6 },
11
11
  text: { color: colors.foreground, fontSize: 13, lineHeight: 19 },
12
12
  lead: { color: colors.accent, fontWeight: "600" as const },
13
- strong: { fontWeight: "500" as const, color: colors.foreground },
13
+ strong: { fontWeight: "600" as const, color: colors.foreground },
14
14
  em: { color: colors.foregroundMuted, fontStyle: "italic" as const },
15
15
  del: { color: colors.foregroundMuted, textDecorationLine: "line-through" as const },
16
16
  link: { color: colors.accent, textDecorationLine: "underline" as const },
@@ -33,6 +33,10 @@ type Styles = ReturnType<typeof makeStyles>;
33
33
  /** Children of a token, if it has any: `Token` is a union, so the optional field needs one cast. */
34
34
  const children = (node: Token | { tokens?: Token[] }): Token[] => (node as { tokens?: Token[] }).tokens ?? [];
35
35
 
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" ? "'" : "&");
39
+
36
40
  function renderInlines(tokens: Token[], styles: Styles, prefix: string, extra?: TextStyle): ReactNode[] {
37
41
  return tokens.map((token, index) => {
38
42
  const key = `${prefix}-${index}`;
@@ -50,7 +54,7 @@ function renderInlines(tokens: Token[], styles: Styles, prefix: string, extra?:
50
54
  default: {
51
55
  const inline = token as Tokens.Generic;
52
56
  if (inline.tokens !== undefined) return nested({});
53
- return inline.text ?? inline.raw;
57
+ return unescape(inline.text ?? inline.raw);
54
58
  }
55
59
  }
56
60
  });
@@ -149,41 +153,20 @@ export function SquareInline({ text, theme, numberOfLines, lead }: {
149
153
  );
150
154
  }
151
155
 
152
- /** A paragraph that is one *emphasis* and nothing else is an action, not a line. */
153
- const isAction = (token: Token): token is Tokens.Paragraph =>
154
- token.type === "paragraph" && children(token).length === 1 && children(token)[0]!.type === "em";
155
-
156
- /** A bell or an action is stage direction: a caption on a hairline across the full width. */
157
- export function SquareCue({ label, theme }: { label: ReactNode; theme: PluginTheme }) {
158
- const rule = { flex: 1, height: StyleSheet.hairlineWidth, backgroundColor: theme.colors.border };
159
- return (
160
- <View style={{ flexDirection: "row", alignItems: "center", gap: 8, marginVertical: 2 }}>
161
- <View style={rule} />
162
- <Text style={{ color: theme.colors.foregroundMuted, fontSize: 11.5, lineHeight: 16, letterSpacing: 0.4 }}>{label}</Text>
163
- <View style={rule} />
164
- </View>
165
- );
166
- }
167
-
168
156
  /**
169
157
  * A body of Markdown: no envelope — the words only, at full width.
170
- * `lead` runs the author into the first line; `author` names whoever performs an action.
158
+ * `lead` runs the author into the first line.
171
159
  */
172
- export function SquareMarkdownBody({ text, theme, lead, author }: {
160
+ export function SquareMarkdownBody({ text, theme, lead }: {
173
161
  text: string;
174
162
  theme: PluginTheme;
175
163
  lead?: string;
176
- author?: string;
177
164
  }) {
178
165
  const styles = useMemo(() => makeStyles(theme.colors), [theme.colors]);
179
166
  const tokens = useMemo(() => lexer(text).filter(token => token.type !== "space"), [text]);
180
167
  let pending = lead;
181
168
  const nodes = tokens.map((token, index) => {
182
169
  const key = `body-${index}`;
183
- if (isAction(token)) {
184
- const words = renderInlines(children(children(token)[0]!), styles, key);
185
- return <SquareCue key={key} theme={theme} label={author === undefined ? words : <>{author} {words}</>} />;
186
- }
187
170
  if (pending === undefined) return renderBlocks([token], styles, key);
188
171
  const name = pending;
189
172
  pending = undefined;
@@ -3,7 +3,7 @@ import { Icon } from "@getpaseo/plugin/client/react-native";
3
3
  import { useMemo, useState } from "react";
4
4
  import { Pressable, ScrollView, Text, View } from "react-native";
5
5
  import { COLLAPSED_PREVIEW_MAX_CHARS, outputPreview, type SquareMessageData } from "../shared/message";
6
- import { MONO, SquareCue, SquareInline, SquareMarkdownBody } from "./markdown";
6
+ import { MONO, SquareInline, SquareMarkdownBody } from "./markdown";
7
7
  import { squarePalette } from "./palette";
8
8
 
9
9
  /**
@@ -12,7 +12,6 @@ import { squarePalette } from "./palette";
12
12
  * Header (both states): ■ origin · authors … refs · how many.
13
13
  * Collapsed: two preview lines. Expanded: the whole scene, at full width.
14
14
  * Each structured message keeps its own author, reference and body.
15
- * A badge or an *action* is stage direction, a caption on a hairline.
16
15
  */
17
16
  export function SquareMessage({ item, theme, layout }: PluginTimelineItemProps<SquareMessageData>) {
18
17
  const [expanded, setExpanded] = useState(false);
@@ -38,7 +37,7 @@ export function SquareMessage({ item, theme, layout }: PluginTimelineItemProps<S
38
37
  // On web the header brightens its details under the cursor; touch keeps them quiet.
39
38
  const detail = layout.platform === "web" && hovered ? foreground : muted;
40
39
  const row = { flexDirection: "row" as const, alignItems: "center" as const, gap: 6 };
41
- const indent = { paddingLeft: 15 };
40
+ const guide = { marginLeft: 2.5, paddingLeft: palette.quote.paddingLeft, borderLeftWidth: palette.quote.borderLeftWidth, borderLeftColor: palette.quote.borderLeftColor };
42
41
 
43
42
  return (
44
43
  <View style={[
@@ -66,22 +65,19 @@ export function SquareMessage({ item, theme, layout }: PluginTimelineItemProps<S
66
65
  <Icon name={expanded ? "ChevronUp" : "ChevronDown"} size={10} color={detail} />
67
66
  </View>
68
67
  {!expanded && preview !== undefined && preview !== "" && (
69
- <View style={{ ...indent, marginTop: 5, marginLeft: 2.5, paddingLeft: palette.quote.paddingLeft, borderLeftWidth: palette.quote.borderLeftWidth, borderLeftColor: palette.quote.borderLeftColor }}>
68
+ <View style={{ ...guide, marginTop: 5 }}>
70
69
  <SquareInline text={preview} theme={palette.theme} numberOfLines={2} lead={chorus && latest && "author" in latest ? latest.author : undefined} />
71
70
  </View>
72
71
  )}
73
72
  </Pressable>
74
73
  {expanded && (
75
- <ScrollView style={{ maxHeight: layout.compact ? 280 : 400, marginTop: 3 }} nestedScrollEnabled>
76
- <View style={{ ...indent, gap: 7 }}>
77
- {items === undefined ? <SquareMarkdownBody text={text ?? ""} theme={palette.theme} /> : items.map((entry, index) => "text" in entry ? (
78
- <SquareMarkdownBody key={index} text={entry.text} theme={palette.theme} />
79
- ) : (
80
- <View key={index} style={{ gap: 6 }}>
81
- {entry.badge
82
- ? <SquareCue theme={palette.theme} label={[[entry.author, entry.badge].filter(Boolean).join(" "), entry.origin, entry.ref].filter(Boolean).join(" · ")} />
83
- : <Text style={{ color: muted, fontSize: 11, fontFamily: MONO }}>{[entry.origin, entry.ref].filter(Boolean).join(" · ")}</Text>}
84
- <SquareMarkdownBody text={entry.body} theme={palette.theme} lead={chorus ? entry.author : undefined} author={entry.author} />
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} />}
85
81
  </View>
86
82
  ))}
87
83
  </View>
@@ -1,4 +1,4 @@
1
- import { Markdown } from '@earendil-works/pi-tui';
1
+ import { Box, Markdown, truncateToWidth, visibleWidth } from '@earendil-works/pi-tui';
2
2
  import { z } from 'zod/v4';
3
3
 
4
4
  import { Square, discover, squareClock } from '../../dist/square-api.js';
@@ -17,16 +17,90 @@ function displayCard(card) {
17
17
  return isCardPresentation(card.details) ? card.details : { text: card.text };
18
18
  }
19
19
 
20
- export function renderSquareCard(message, { outputPad = 1 }, theme) {
21
- if (typeof message.content !== 'string') return undefined;
22
- const color = (name) => (text) => theme.fg?.(name, text) ?? text;
23
- const style = (name) => (text) => theme[name]?.(text) ?? text;
24
- return new Markdown(message.content, outputPad, 0, {
20
+ const fg = (theme, name) => (text) => theme.fg?.(name, text) ?? text;
21
+ const style = (theme, name) => (text) => theme[name]?.(text) ?? text;
22
+
23
+ function markdownTheme(theme) {
24
+ const color = (name) => fg(theme, name);
25
+ return {
25
26
  heading: color('mdHeading'), link: color('mdLink'), linkUrl: color('mdLinkUrl'), code: color('mdCode'),
26
27
  codeBlock: color('mdCodeBlock'), codeBlockBorder: color('mdCodeBlockBorder'), quote: color('mdQuote'),
27
28
  quoteBorder: color('mdQuoteBorder'), hr: color('mdHr'), listBullet: color('mdListBullet'),
28
- bold: style('bold'), italic: style('italic'), underline: style('underline'), strikethrough: style('strikethrough'),
29
- });
29
+ bold: style(theme, 'bold'), italic: (text) => fg(theme, 'muted')(style(theme, 'italic')(text)),
30
+ underline: style(theme, 'underline'), strikethrough: style(theme, 'strikethrough'),
31
+ };
32
+ }
33
+
34
+ /** Keep the short references; the long origin is the part that may ellipsize. */
35
+ function cardHeader(left, right, width) {
36
+ if (!right) return truncateToWidth(left, width);
37
+ const rightWidth = visibleWidth(right);
38
+ if (rightWidth + 4 >= width) return truncateToWidth(left, width);
39
+ const leftWidth = width - rightWidth - 2;
40
+ return `${truncateToWidth(left, leftWidth)}${' '.repeat(width - Math.min(visibleWidth(left), leftWidth) - rightWidth)}${right}`;
41
+ }
42
+
43
+ const escapeMarkdown = text => text.replace(/[!"#$%&'()*+,\-./:;<=>?@[\\\]^_`{|}~]/g, '\\$&');
44
+ const shortOrigin = origin => origin.split(/[\\/]/).filter(Boolean).at(-1) || origin;
45
+
46
+ /** The same quiet quote guide in collapsed and expanded states. */
47
+ function guidedMarkdown(text, theme, { preview = false, lead } = {}) {
48
+ const source = lead ? `**${escapeMarkdown(lead)}** ${text}` : text;
49
+ const markdown = new Markdown(source, 0, 0, markdownTheme(theme), { color: fg(theme, 'customMessageText') });
50
+ const guide = fg(theme, 'mdQuoteBorder')('│ ');
51
+ return {
52
+ invalidate() { markdown.invalidate(); },
53
+ render(width) {
54
+ const contentWidth = Math.max(1, width - 2);
55
+ let lines = markdown.render(contentWidth);
56
+ if (preview) {
57
+ const visible = lines.filter(line => line.trim() !== '');
58
+ const clipped = visible.length > 2 || text.length >= 1_000;
59
+ lines = visible.slice(0, 2);
60
+ if (clipped && lines.length) lines[lines.length - 1] = truncateToWidth(`${lines.at(-1).trimEnd()}${fg(theme, 'dim')('…')}`, contentWidth);
61
+ }
62
+ return lines.map(line => guide + line);
63
+ },
64
+ };
65
+ }
66
+
67
+ /** People get the producer's display facts, never the model-facing reminder envelope. */
68
+ export function renderSquareCard(message, { expanded, outputPad = 1 }, theme) {
69
+ if (typeof message.content !== 'string') return undefined;
70
+ const items = message.details?.items;
71
+ if (!Array.isArray(items) || items.length === 0
72
+ || items.some(item => !isCardPresentation(item) && typeof item?.text !== 'string')) {
73
+ return new Markdown(message.content, outputPad, 0, markdownTheme(theme));
74
+ }
75
+ const presentations = items.filter(isCardPresentation);
76
+ const authors = [...new Set(presentations.flatMap(item => item.author === undefined ? [] : [item.author]))];
77
+ const origins = [...new Set(presentations.map(item => item.origin))];
78
+ const originName = origins.map(shortOrigin).join(', ') || 'Notification';
79
+ const badge = presentations.at(-1)?.badge;
80
+ const byline = [authors.join(', '), badge].filter(Boolean).join(' · ');
81
+ const refs = presentations.flatMap(item => item.ref === undefined ? []
82
+ : [origins.length > 1 ? `${shortOrigin(item.origin)} ${item.ref}` : item.ref]).join(', ');
83
+ const trailing = [refs, presentations.length > 1 ? String(presentations.length) : undefined].filter(Boolean).join(' · ');
84
+ const box = new Box(outputPad, 1, text => theme.bg?.('customMessageBg', text) ?? text);
85
+ const left = `${fg(theme, 'accent')('■')} ${style(theme, 'bold')(originName)}${byline ? fg(theme, 'muted')(` · ${byline}`) : ''}`;
86
+ const right = trailing ? fg(theme, 'dim')(trailing) : '';
87
+ box.addChild({ invalidate() {}, render(width) { return [cardHeader(left, right, width)]; } });
88
+ const bodyOf = item => isCardPresentation(item) ? item.body : item.text;
89
+ if (!expanded) {
90
+ const body = bodyOf(items.at(-1));
91
+ if (body.trim()) box.addChild(guidedMarkdown(body.slice(0, 1_000).replace(/[\uD800-\uDBFF]$/, ''), theme, { preview: true }));
92
+ return box;
93
+ }
94
+ for (const [index, item] of items.entries()) {
95
+ if (index > 0) box.addChild({
96
+ invalidate() {},
97
+ render(width) { return [`${fg(theme, 'mdQuoteBorder')('│ ')}${fg(theme, 'borderMuted')('─'.repeat(Math.max(1, width - 2)))}`]; },
98
+ });
99
+ box.addChild(guidedMarkdown(bodyOf(item), theme, {
100
+ lead: authors.length > 1 && isCardPresentation(item) ? item.author : undefined,
101
+ }));
102
+ }
103
+ return box;
30
104
  }
31
105
 
32
106
  export default function squarePiExtension(pi) {