@astrosheep/square 0.3.91 → 0.3.93

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 (44) hide show
  1. package/dist/cli/context.js +2 -1
  2. package/dist/codex-address.d.ts +3 -0
  3. package/dist/codex-address.js +8 -0
  4. package/dist/codex-boundary-state.d.ts +6 -8
  5. package/dist/codex-boundary-state.js +8 -85
  6. package/dist/codex-hook.d.ts +1 -3
  7. package/dist/codex-hook.js +20 -76
  8. package/dist/codex-queue.d.ts +5 -17
  9. package/dist/codex-queue.js +21 -64
  10. package/dist/file-lock.d.ts +1 -17
  11. package/dist/file-lock.js +1 -64
  12. package/dist/harness-links.d.ts +1 -10
  13. package/dist/harness-links.js +144 -109
  14. package/dist/harness.js +4 -13
  15. package/dist/mcp.js +2 -1
  16. package/dist/packages/agent-delivery/src/codex-boundary.d.ts +13 -0
  17. package/dist/packages/agent-delivery/src/codex-boundary.js +75 -0
  18. package/dist/packages/agent-delivery/src/codex-cli.d.ts +10 -0
  19. package/dist/packages/agent-delivery/src/codex-cli.js +69 -0
  20. package/dist/packages/agent-delivery/src/codex-endpoint.d.ts +3 -0
  21. package/dist/packages/agent-delivery/src/codex-endpoint.js +14 -0
  22. package/dist/packages/agent-delivery/src/codex-hook.d.ts +26 -0
  23. package/dist/packages/agent-delivery/src/codex-hook.js +58 -0
  24. package/dist/packages/agent-delivery/src/codex-queue.d.ts +24 -0
  25. package/dist/packages/agent-delivery/src/codex-queue.js +58 -0
  26. package/dist/packages/agent-delivery/src/codex.d.ts +7 -0
  27. package/dist/packages/agent-delivery/src/codex.js +332 -0
  28. package/dist/packages/agent-delivery/src/errors.d.ts +1 -1
  29. package/dist/packages/agent-delivery/src/errors.js +1 -0
  30. package/dist/packages/agent-delivery/src/file-lock.d.ts +17 -0
  31. package/dist/packages/agent-delivery/src/file-lock.js +64 -0
  32. package/dist/packages/agent-delivery/src/index.d.ts +17 -3
  33. package/dist/packages/agent-delivery/src/index.js +29 -4
  34. package/dist/packages/agent-delivery/src/types.d.ts +58 -3
  35. package/dist/square-location.js +2 -1
  36. package/guides/codex-delivery.md +79 -0
  37. package/package.json +2 -1
  38. package/plugins/claude/.claude-plugin/plugin.json +1 -1
  39. package/plugins/codex/.codex-plugin/plugin.json +1 -1
  40. package/plugins/codex/hooks/hooks.json +3 -0
  41. package/plugins/paseo/client/markdown.tsx +16 -38
  42. package/plugins/paseo/client/palette.ts +62 -0
  43. package/plugins/paseo/client/square-message.tsx +75 -45
  44. package/plugins/paseo/shared/message.ts +16 -1
@@ -1,3 +1,4 @@
1
+ import type { CodexBoundaryStore } from './codex-boundary.js';
1
2
  declare const targetBrand: unique symbol;
2
3
  export interface OpenCodeTarget {
3
4
  readonly harness: 'opencode';
@@ -19,7 +20,12 @@ export interface PaseoTarget {
19
20
  readonly agentId: string;
20
21
  readonly [targetBrand]: true;
21
22
  }
22
- export type Target = OpenCodeTarget | ClaudeTarget | PiTarget | PaseoTarget;
23
+ export interface CodexTarget {
24
+ readonly harness: 'codex';
25
+ readonly sessionId: string;
26
+ readonly [targetBrand]: true;
27
+ }
28
+ export type Target = OpenCodeTarget | ClaudeTarget | PiTarget | PaseoTarget | CodexTarget;
23
29
  export type Delivery = 'steer' | 'queue';
24
30
  export interface OpenCodeSendTextOptions {
25
31
  readonly delivery?: Delivery;
@@ -45,7 +51,13 @@ export interface PaseoSendTextOptions {
45
51
  readonly timeoutMs?: number;
46
52
  readonly signal?: AbortSignal;
47
53
  }
48
- export type SendTextOptions = OpenCodeSendTextOptions | ClaudeSendTextOptions | PiSendTextOptions | PaseoSendTextOptions;
54
+ export interface CodexSendTextOptions {
55
+ readonly delivery?: Delivery;
56
+ readonly inputId?: never;
57
+ readonly timeoutMs?: number;
58
+ readonly signal?: AbortSignal;
59
+ }
60
+ export type SendTextOptions = OpenCodeSendTextOptions | ClaudeSendTextOptions | PiSendTextOptions | PaseoSendTextOptions | CodexSendTextOptions;
49
61
  export interface OpenCodeConnectOptions {
50
62
  readonly harness: 'opencode';
51
63
  readonly sessionId: string;
@@ -88,6 +100,32 @@ export interface PaseoConnectOptions {
88
100
  readonly timeoutMs?: number;
89
101
  readonly signal?: AbortSignal;
90
102
  }
103
+ /** Connect only to an existing app-server; never starts a service or resumes a thread. */
104
+ export interface CodexAppServerConnectOptions {
105
+ readonly harness: 'codex';
106
+ readonly transport?: 'app-server';
107
+ readonly sessionId: string;
108
+ readonly endpoint?: string;
109
+ readonly token?: string;
110
+ readonly codexHome?: string;
111
+ readonly timeoutMs?: number;
112
+ readonly signal?: AbortSignal;
113
+ }
114
+ /** Bind CLI coordinates and the same lifecycle source used by the receiver's hooks. */
115
+ export interface CodexCliConnectOptions {
116
+ readonly harness: 'codex';
117
+ readonly transport: 'cli';
118
+ readonly sessionId: string;
119
+ readonly boundaries: Pick<CodexBoundaryStore, 'queueEligible'>;
120
+ readonly codexHome?: string;
121
+ readonly endpoint?: string;
122
+ readonly bin?: string;
123
+ readonly args?: readonly string[];
124
+ readonly env?: NodeJS.ProcessEnv;
125
+ readonly timeoutMs?: number;
126
+ readonly signal?: AbortSignal;
127
+ }
128
+ export type CodexConnectOptions = CodexAppServerConnectOptions | CodexCliConnectOptions;
91
129
  interface OpenCodeAttempt {
92
130
  readonly harness: 'opencode';
93
131
  readonly sessionId: string;
@@ -158,5 +196,22 @@ export type PaseoDeliveryResult = (PaseoAttempt & {
158
196
  readonly state: 'unknown';
159
197
  readonly code: 'timeout' | 'aborted' | 'transport';
160
198
  });
161
- export type DeliveryResult = OpenCodeDeliveryResult | ClaudeDeliveryResult | PiDeliveryResult | PaseoDeliveryResult;
199
+ interface CodexAttempt {
200
+ readonly harness: 'codex';
201
+ readonly sessionId: string;
202
+ readonly inputId: string;
203
+ }
204
+ export type CodexDeliveryResult = (CodexAttempt & {
205
+ readonly state: 'admitted';
206
+ }) | (CodexAttempt & {
207
+ readonly state: 'unknown';
208
+ readonly code: 'timeout' | 'aborted' | 'transport' | 'invalid_response';
209
+ }) | (CodexAttempt & {
210
+ readonly state: 'unavailable';
211
+ readonly code: 'timeout' | 'aborted' | 'transport' | 'authentication_failed';
212
+ }) | (CodexAttempt & {
213
+ readonly state: 'rejected';
214
+ readonly code: 'not_found' | 'refused' | 'unsupported_version';
215
+ });
216
+ export type DeliveryResult = OpenCodeDeliveryResult | ClaudeDeliveryResult | PiDeliveryResult | PaseoDeliveryResult | CodexDeliveryResult;
162
217
  export {};
@@ -3,7 +3,8 @@ import os from 'node:os';
3
3
  import { readFile } from 'node:fs/promises';
4
4
  import { SquareError } from './model.js';
5
5
  export function parseSquareLocation(value) {
6
- if (!/^[A-Za-z][A-Za-z0-9+.-]*:/.test(value))
6
+ // Windows roots include drive-relative prefixes such as D:.
7
+ if (path.win32.parse(value).root !== '' || !/^[A-Za-z][A-Za-z0-9+.-]*:/.test(value))
7
8
  return { kind: 'local', path: value };
8
9
  // Parse the raw components, before URL dot-segment normalization or host lowercasing.
9
10
  const match = /^stdio:\/\/([A-Za-z0-9_-]+)\/([^/?#]+)$/.exec(value);
@@ -0,0 +1,79 @@
1
+ # Codex delivery
2
+
3
+ Square uses the shared Codex leaves in `packages/agent-delivery/src`, rather than
4
+ maintaining a second hook protocol, boundary store or CLI sender. The standalone
5
+ package's [README](../packages/agent-delivery/README.md) describes both its native
6
+ app-server capability and its host-owned hook integration.
7
+
8
+ ## Square's hook path
9
+
10
+ `plugins/codex/hooks/hooks.json` invokes `square codex-hook`:
11
+
12
+ - `SessionStart`, `SessionResume` and `UserPromptSubmit` invalidate the previous
13
+ Stop observation. `SessionEnd` marks the session ended and ends Square's owned
14
+ memberships. Later Stop/tool callbacks cannot re-enable queue until a new
15
+ `SessionStart` or `SessionResume`.
16
+ - `PostToolUse` asks Square for current pending activity and returns the existing
17
+ bounded `additionalContext` presentation.
18
+ - `Stop` records queue eligibility and sweeps pending work, but does not itself
19
+ claim that a preview was presented.
20
+
21
+ The shared handler owns hook framing, lifecycle sequencing, its cancellation
22
+ budget and bounded sweeps. Square supplies activity selection, formatting,
23
+ artifact-owned presentation claims, cancellation, membership cleanup and
24
+ pending-work sweeps. The handler cooperatively awaits Square's pull: the signal
25
+ cancels work before presentation, but a successful presentation's `settleAs`
26
+ finishes independently of request cancellation. Even when that settlement crosses
27
+ the internal budget, the hook waits for it and returns the associated context;
28
+ it never abandons settlement that can mark activity seen without returning text.
29
+ After expiry, no sweep starts, and a sweep timeout cannot erase successful pull
30
+ output. Settlement must still finish within Codex's external hook timeout. Hook inbox lookup uses
31
+ an exact Body (`{ channel: 'codex', session }`), not a bare session id. The shared
32
+ boundary store uses Square's caller-selected `SQUARE_CODEX_BOUNDARIES` file,
33
+ defaulting to `~/.square/codex-boundaries.json`; its JSON observations are not
34
+ conversation storage and are not proof of current native execution or consumption.
35
+ Square's format5 artifact remains authoritative for seats and delivery evidence.
36
+
37
+ `CodexQueueTransport` implements the existing `WakeTransportPort` and uses the public
38
+ `connect({ harness: 'codex', transport: 'cli', sessionId, boundaries, ... })`
39
+ and `agent.queue(text)` interface, just like independent hosts. Square reads its
40
+ stored pending preview and finishes its currentness gate first; the bound client
41
+ checks boundary eligibility once inside `queue()`, then launches the CLI. No
42
+ caller-side lifecycle check is duplicated. Outcomes continue through the existing
43
+ artifact-owned delivery path; no host ledger or new route kind is introduced.
44
+
45
+ Both CLI coordinate creation and MCP call-local Codex coordinates capture the
46
+ recipient's `CODEX_HOME` (or home default), so later senders use the recipient's
47
+ CLI configuration rather than their own. Only an explicitly configured
48
+ `SQUARE_CODEX_ENDPOINT` adds a remote endpoint. `SQUARE_CODEX_BIN` remains
49
+ Square's executable selection; it is not an environment convention of the
50
+ independent package.
51
+
52
+ By default the sender runs `codex queue --thread ... --message ...`, allowing the
53
+ CLI's normal connection/startup behavior. Only an explicit endpoint adds
54
+ `--remote`. This does not make another host's private stdio pipe attachable.
55
+ After an observed `SessionEnd`, Square does not dispatch queue; the bound lifecycle
56
+ check also catches an end observed while the currentness gate was running.
57
+ It cannot retract a submission already launched or infer a missed end hook.
58
+ Queue success means native admission only; its receipt id is a local attempt
59
+ UUID, not a server message id. The child runs asynchronously and respects the
60
+ send's abort signal/deadline. Timeout, killed child and unexplained post-launch
61
+ failures remain unknown, not a safe reason to resend. A receiver with no tool
62
+ calls waits for Stop; this path is not instantaneous native steer.
63
+
64
+ ## Optional native app-server capability
65
+
66
+ Independent consumers can use `connect({ harness: 'codex', sessionId, endpoint })`
67
+ from `@astrosheep/agent-delivery` for fenced busy steer and native queue.
68
+ `transport: 'app-server'` is the explicit spelling and the default when omitted;
69
+ it exposes both methods. `transport: 'cli'` exposes queue only and requires the
70
+ host's hook lifecycle source at connection time. There is no automatic fallback
71
+ between transports. The app-server transport never starts a service or calls
72
+ `thread/resume`. This does not replace Square's hook policy or add a new persisted
73
+ Square route kind. A loaded thread's owner remains its existing host, not the
74
+ external sender.
75
+
76
+ Neither path implements a read-receipt roundtrip. Admission and runtime
77
+ observations must not be presented as model comprehension or completion. An
78
+ unknown outcome stays unknown. Installing, restarting or changing a real Codex
79
+ server is outside this integration.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@astrosheep/square",
3
- "version": "0.3.91",
3
+ "version": "0.3.93",
4
4
  "description": "A shared public square where agents join, catch activity, express, and step out when done.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -80,6 +80,7 @@
80
80
  "dependencies": {
81
81
  "@modelcontextprotocol/sdk": "^1.32.0",
82
82
  "cross-spawn": "^7.0.6",
83
+ "jsonc-parser": "^3.3.1",
83
84
  "zod": "^4.6.5"
84
85
  },
85
86
  "peerDependencies": {
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "square",
3
- "version": "0.3.91",
3
+ "version": "0.3.93",
4
4
  "description": "Native Claude Code inbox delivery for Square participants",
5
5
  "types": "./types.d.ts",
6
6
  "author": {
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "square",
3
- "version": "0.3.91",
3
+ "version": "0.3.93",
4
4
  "description": "Shared Square activity with reliable participant attention at Codex boundaries.",
5
5
  "author": {
6
6
  "name": "Square"
@@ -9,6 +9,9 @@
9
9
  "SessionEnd": [
10
10
  { "hooks": [{ "type": "command", "command": "square codex-hook", "timeout": 3 }] }
11
11
  ],
12
+ "UserPromptSubmit": [
13
+ { "hooks": [{ "type": "command", "command": "square codex-hook", "timeout": 5 }] }
14
+ ],
12
15
  "PostToolUse": [
13
16
  {
14
17
  "hooks": [
@@ -1,10 +1,7 @@
1
1
  import { useMemo, type ReactNode } from "react";
2
- import { Platform, Text, View, type TextStyle } from "react-native";
2
+ import { Platform, Text, View, type TextStyle, type ViewStyle } from "react-native";
3
3
  import type { PluginTheme } from "@getpaseo/plugin";
4
4
  import { Lexer, lexer, type Token, type Tokens } from "marked";
5
- import { parseSquareBlocks } from "../shared/message.ts";
6
-
7
- export { parseSquareBlocks };
8
5
 
9
6
  export const MONO = Platform.select({ ios: "Menlo", android: "monospace", default: "ui-monospace, Menlo, monospace" });
10
7
 
@@ -12,30 +9,21 @@ function makeStyles(colors: PluginTheme["colors"]) {
12
9
  return {
13
10
  block: { gap: 6 },
14
11
  text: { color: colors.foreground, fontSize: 13, lineHeight: 19 },
15
- strong: { fontWeight: "700" as const },
16
- em: { fontStyle: "italic" as const },
17
- del: { textDecorationLine: "line-through" as const },
12
+ strong: { fontWeight: "500" as const, color: colors.foreground },
13
+ em: { color: colors.foregroundMuted, fontStyle: "italic" as const },
14
+ del: { color: colors.foregroundMuted, textDecorationLine: "line-through" as const },
18
15
  link: { color: colors.accent, textDecorationLine: "underline" as const },
19
- inlineCode: { fontFamily: MONO, fontSize: 12, backgroundColor: colors.surface2, color: colors.foreground },
20
- codeBlock: {
21
- backgroundColor: colors.surface0,
22
- borderRadius: 8,
23
- borderWidth: 1,
24
- borderColor: colors.border,
25
- paddingHorizontal: 9,
26
- paddingVertical: 7,
27
- },
28
- codeText: { fontFamily: MONO, fontSize: 11.5, lineHeight: 17, color: colors.foreground },
16
+ inlineCode: { fontFamily: MONO },
17
+ codeBlock: { backgroundColor: colors.surface0, borderRadius: 4, paddingHorizontal: 8, paddingVertical: 6 },
18
+ codeText: { fontFamily: MONO, fontSize: 13, lineHeight: 19, color: colors.foreground },
29
19
  quote: { borderLeftWidth: 2, borderLeftColor: colors.border, paddingLeft: 8, gap: 2 },
30
20
  quoteText: { color: colors.foregroundMuted, fontStyle: "italic" as const },
31
- meta: { color: colors.foregroundMuted, fontSize: 11, lineHeight: 15, fontFamily: MONO },
32
- actor: { color: colors.accent, fontSize: 12, lineHeight: 17 },
33
21
  separator: { height: 1, backgroundColor: colors.border, marginVertical: 4 },
34
22
  listRow: { flexDirection: "row" as const, gap: 6 },
35
23
  listItem: { flexShrink: 1, gap: 2 },
36
- bullet: { color: colors.accent, fontSize: 13, lineHeight: 19 },
24
+ bullet: { color: colors.foregroundMuted, fontSize: 13, lineHeight: 19 },
37
25
  tableRow: { flexDirection: "row" as const, gap: 8 },
38
- tableCell: { flex: 1, fontSize: 12 },
26
+ tableCell: { flex: 1 },
39
27
  };
40
28
  }
41
29
 
@@ -55,7 +43,7 @@ function renderInlines(tokens: Token[], styles: Styles, prefix: string, extra?:
55
43
  case "em": return nested(styles.em);
56
44
  case "del": return nested(styles.del);
57
45
  case "link": return nested(styles.link);
58
- case "codespan": return <Text key={key} style={[styles.text, extra, styles.inlineCode]}>{` ${token.text} `}</Text>;
46
+ case "codespan": return <Text key={key} style={[styles.text, extra, styles.inlineCode]}>{token.text}</Text>;
59
47
  case "br": return "\n";
60
48
  case "image": return token.text;
61
49
  default: {
@@ -86,7 +74,7 @@ function renderBlocks(tokens: Token[], styles: Styles, prefix: string, textStyle
86
74
  );
87
75
  case "heading":
88
76
  return (
89
- <Text key={key} style={[styles.text, textStyle, styles.strong, { fontSize: Math.max(13, 18 - token.depth) }]}>
77
+ <Text key={key} style={[styles.text, textStyle, styles.strong]}>
90
78
  {renderInlines(children(token), styles, key)}
91
79
  </Text>
92
80
  );
@@ -100,7 +88,7 @@ function renderBlocks(tokens: Token[], styles: Styles, prefix: string, textStyle
100
88
  {list.items.map((item, itemIndex) => (
101
89
  <View key={`${key}-${itemIndex}`} style={styles.listRow}>
102
90
  <Text style={styles.bullet}>
103
- {item.task ? (item.checked === true ? "☑" : "☐") : list.ordered ? `${start + itemIndex}.` : "•"}
91
+ {item.task ? (item.checked === true ? "☑" : "☐") : list.ordered ? `${start + itemIndex}.` : "·"}
104
92
  </Text>
105
93
  <View style={styles.listItem}>{renderBlocks(children(item), styles, `${key}-${itemIndex}`, textStyle)}</View>
106
94
  </View>
@@ -153,19 +141,9 @@ export function SquareInline({ text, theme, numberOfLines, style }: {
153
141
  );
154
142
  }
155
143
 
156
- export function SquareMarkdown({ text, theme }: { text: string; theme: PluginTheme }) {
144
+ /** A body of Markdown: no envelope, no route line — the words only, indented by the card. */
145
+ export function SquareMarkdownBody({ text, theme, style }: { text: string; theme: PluginTheme; style?: ViewStyle }) {
157
146
  const styles = useMemo(() => makeStyles(theme.colors), [theme.colors]);
158
- const blocks = useMemo(() => parseSquareBlocks(text), [text]);
159
- return (
160
- <View style={styles.block}>
161
- {blocks.map((block, index) => (
162
- <View key={index} style={styles.block}>
163
- {index > 0 && <View style={styles.separator} />}
164
- {block.meta !== undefined && <Text style={styles.meta}>{block.meta}</Text>}
165
- {block.actor !== undefined && <Text style={styles.actor}>{block.actor}</Text>}
166
- {renderBlocks(lexer(block.body), styles, `${index}`)}
167
- </View>
168
- ))}
169
- </View>
170
- );
147
+ const tokens = useMemo(() => lexer(text), [text]);
148
+ return <View style={[styles.block, style]}>{renderBlocks(tokens, styles, "body")}</View>;
171
149
  }
@@ -0,0 +1,62 @@
1
+ import type { PluginTheme } from "@getpaseo/plugin";
2
+
3
+ /**
4
+ * Square's own courtyard palette: cool pewter slate and a cold blue voice,
5
+ * deliberately no relative of notesoup's warm parchment or pi-meantime's
6
+ * neutral carbon. Every token here is opaque and self-contained — the card and
7
+ * Markdown never borrow the host's surface ramp or accent, only its appearance
8
+ * (read from the host ink) and its accentForeground, which paints text on top
9
+ * of an accent fill.
10
+ */
11
+ const DARK = {
12
+ surface0: "#121519", // inset / code
13
+ surface1: "#171b20", // card
14
+ surface2: "#121519", // inset / code
15
+ border: "#262d36",
16
+ foreground: "#e6ebf2",
17
+ foregroundMuted: "#7d8999",
18
+ accent: "#60a5fa", // plaza ■ / links
19
+ } as const;
20
+
21
+ const LIGHT = {
22
+ surface0: "#e8edf3",
23
+ surface1: "#f3f6fa", // pewter slate, not notesoup's warm parchment
24
+ surface2: "#e8edf3",
25
+ border: "#d5dee8",
26
+ foreground: "#141b24",
27
+ foregroundMuted: "#64748b",
28
+ accent: "#2563eb",
29
+ } as const;
30
+
31
+ const VOICE = { dark: "#93c5fd", light: "#3b82f6" } as const;
32
+
33
+ export interface SquarePalette {
34
+ /** Complete theme for the card and its Markdown surfaces. */
35
+ theme: PluginTheme;
36
+ /** The filled ● speaker dot; the hollow ○ keeps the muted ink instead. */
37
+ voice: string;
38
+ }
39
+
40
+ /** Paseo theme colors are hex (#RGB or #RRGGBB). */
41
+ function brightness(hex: string): number {
42
+ const rgb = hex.length === 4 ? hex.slice(1).replace(/./g, digit => digit + digit) : hex.slice(1, 7);
43
+ return 0.299 * parseInt(rgb.slice(0, 2), 16)
44
+ + 0.587 * parseInt(rgb.slice(2, 4), 16)
45
+ + 0.114 * parseInt(rgb.slice(4, 6), 16);
46
+ }
47
+
48
+ export function squarePalette(theme: PluginTheme): SquarePalette {
49
+ // The SDK exposes colors, not appearance. Use the host theme, never the OS color scheme.
50
+ const dark = brightness(theme.colors.foreground) > brightness(theme.colors.surface0);
51
+ const palette = dark ? DARK : LIGHT;
52
+ return {
53
+ theme: { colors: {
54
+ ...palette,
55
+ accentForeground: theme.colors.accentForeground,
56
+ statusSuccess: theme.colors.statusSuccess,
57
+ statusWarning: theme.colors.statusWarning,
58
+ statusDanger: theme.colors.statusDanger,
59
+ } },
60
+ voice: dark ? VOICE.dark : VOICE.light,
61
+ };
62
+ }
@@ -1,69 +1,99 @@
1
1
  import type { PluginTimelineItemProps } from "@getpaseo/plugin/client";
2
2
  import { Icon } from "@getpaseo/plugin/client/react-native";
3
3
  import { useMemo, useState } from "react";
4
- import { Pressable, ScrollView, Text, View } from "react-native";
5
- import { outputPreview, squareHeadline, type SquareMessageData } from "../shared/message";
6
- import { SquareInline, SquareMarkdown } from "./markdown";
4
+ import { Pressable, ScrollView, StyleSheet, Text, View } from "react-native";
5
+ import { parseSquareBlocks, parseSquareVoice, type SquareMessageData } from "../shared/message";
6
+ import { MONO, SquareInline, SquareMarkdownBody } from "./markdown";
7
+ import { squarePalette } from "./palette";
7
8
 
8
- /** A plain card: who, what they did, where, then what they said. No badge, no chrome — the words carry the place. */
9
+ /**
10
+ * The accent ■ marks the square itself, not a route.
11
+ * Collapsed: ■ who spoke · where · how many, then two preview lines.
12
+ * Expanded: the same header, then the bodies indented under it. Several voices
13
+ * become a stage script: each speaker holds a fixed left column, their words
14
+ * run down a single ruled dialogue column. No narration.
15
+ */
9
16
  export function SquareMessage({ item, theme, layout }: PluginTimelineItemProps<SquareMessageData>) {
10
17
  const [expanded, setExpanded] = useState(false);
18
+ const palette = useMemo(() => squarePalette(theme), [theme]);
11
19
  const { text, items } = item.data;
12
20
  const latest = items?.[items.length - 1];
13
- // Transform already bounded the collapsed preview; only normalise whitespace for the single line.
14
21
  const preview = latest?.preview?.replace(/\s+/g, " ").trim();
15
- const muted = theme.colors.foregroundMuted;
16
- const foreground = theme.colors.foreground;
17
-
18
- const styles = useMemo(() => ({
19
- card: {
20
- marginVertical: 3,
21
- marginRight: layout.compact ? 0 : 8,
22
- backgroundColor: theme.colors.surface1,
23
- borderWidth: 1,
24
- borderColor: theme.colors.border,
25
- borderRadius: 6,
26
- paddingHorizontal: 12,
27
- paddingVertical: 8,
28
- gap: 4,
29
- },
30
- whoRow: { flexDirection: "row" as const, alignItems: "center" as const, gap: 6 },
31
- glyph: { color: theme.colors.accent, fontSize: 9 },
32
- actor: { color: foreground, fontSize: 12.5, fontWeight: "600" as const },
33
- route: { color: muted, fontSize: 12.5, flex: 1 },
34
- count: { color: muted, fontSize: 11 },
35
- }), [foreground, layout.compact, muted, theme.colors.accent, theme.colors.border, theme.colors.surface1]);
36
-
37
- const headline = latest !== undefined ? squareHeadline(latest) : "Square context";
38
- const routeText = latest?.actor !== undefined && headline.startsWith(latest.actor)
39
- ? headline.slice(latest.actor.length).trim()
40
- : headline;
22
+ const blocks = useMemo(() => parseSquareBlocks(text), [text]);
23
+ const multi = blocks.length > 1;
24
+ const single = multi ? undefined : parseSquareVoice(blocks[0].actor, blocks[0].meta);
25
+ const names = items !== undefined
26
+ ? items.map(entry => entry.actor)
27
+ : blocks.map(block => parseSquareVoice(block.actor, block.meta).speaker);
28
+ const speakers = [...new Set(names.filter((name): name is string => name !== undefined))];
29
+ const squareName = latest?.square ?? blocks[0].meta?.split("·")[1]?.trim() ?? "Square";
30
+ const bell = latest?.route === "bell" || single?.bell === true;
31
+ const trailing = `· ${squareName}${expanded && single?.actId ? ` · ${single.actId}` : !expanded && items && items.length > 1 ? ` · ${items.length}` : ""}`;
32
+ const { accent, border, foreground, foregroundMuted: muted, surface1 } = palette.theme.colors;
33
+ const row = { flexDirection: "row" as const, alignItems: "center" as const, gap: 6 };
34
+ const indent = { paddingLeft: 15 };
41
35
 
42
36
  return (
43
- <View style={styles.card}>
37
+ <View style={{ backgroundColor: surface1, borderWidth: StyleSheet.hairlineWidth, borderColor: border, borderRadius: 8, paddingHorizontal: 12, paddingVertical: 9, gap: expanded ? 8 : 3 }}>
44
38
  <Pressable
45
39
  accessibilityRole="button"
46
- accessibilityLabel={`${expanded ? "收起" : "展开"} ${latest?.square ?? "square"}: ${headline}`}
40
+ accessibilityLabel={`${expanded ? "收起" : "展开"} ${squareName}: ${speakers.join(", ") || "Square"}`}
47
41
  aria-expanded={expanded}
48
42
  onPress={() => setExpanded(value => !value)}
49
- style={({ pressed }) => ({ gap: 4, opacity: pressed ? 0.7 : 1 })}
43
+ style={({ pressed }) => ({ gap: 3, opacity: pressed ? 0.7 : 1 })}
50
44
  >
51
- <View style={styles.whoRow}>
52
- <Text style={styles.glyph}>■</Text>
53
- {latest?.actor !== undefined && <Text style={styles.actor}>{latest.actor}</Text>}
54
- <Text numberOfLines={1} style={styles.route}>
55
- {routeText}{latest?.square !== undefined && ` · ${latest.square}`}
56
- </Text>
57
- {items !== undefined && items.length > 1 && <Text style={styles.count}>{items.length}</Text>}
58
- <Icon name={expanded ? "ChevronUp" : "ChevronDown"} size={16} color={muted} />
45
+ <View style={expanded && multi
46
+ ? { ...row, paddingBottom: 7, borderBottomWidth: StyleSheet.hairlineWidth, borderBottomColor: border }
47
+ : row}>
48
+ <Text style={{ color: accent, fontSize: 9 }}>■</Text>
49
+ {expanded && multi ? (
50
+ <>
51
+ <Text numberOfLines={1} style={{ color: foreground, fontSize: 12.5, fontWeight: "500", flex: 1 }}>{squareName}</Text>
52
+ <Icon name="ChevronUp" size={15} color={muted} />
53
+ </>
54
+ ) : (
55
+ <>
56
+ <Text numberOfLines={1} style={{ color: foreground, fontSize: 13, fontWeight: "500", flex: 1 }}>
57
+ {speakers.join(", ") || "Square"}
58
+ {bell && <Text style={{ color: muted, fontWeight: "400" }}> · rang the bell</Text>}
59
+ </Text>
60
+ <Text style={{ color: muted, fontSize: 12 }}>{trailing}</Text>
61
+ <Icon name={expanded ? "ChevronUp" : "ChevronDown"} size={15} color={muted} />
62
+ </>
63
+ )}
59
64
  </View>
60
65
  {!expanded && preview !== undefined && preview !== "" && (
61
- <SquareInline text={preview} theme={theme} numberOfLines={2} />
66
+ <View style={indent}>
67
+ <SquareInline text={preview} theme={palette.theme} numberOfLines={2} />
68
+ </View>
62
69
  )}
63
70
  </Pressable>
64
71
  {expanded && (
65
- <ScrollView style={{ maxHeight: layout.compact ? 280 : 400, marginTop: 2, borderTopWidth: 1, borderTopColor: theme.colors.border }} contentContainerStyle={{ paddingTop: 8 }} nestedScrollEnabled>
66
- <SquareMarkdown text={outputPreview(text)} theme={theme} />
72
+ <ScrollView style={{ maxHeight: layout.compact ? 280 : 400 }} nestedScrollEnabled>
73
+ {multi ? (
74
+ <View style={{ gap: 10 }}>
75
+ {blocks.map((block, index) => {
76
+ const voice = parseSquareVoice(block.actor, block.meta);
77
+ const detail = [voice.bell ? "bell" : undefined, voice.actId].filter(Boolean).join(" · ");
78
+ return (
79
+ <View key={voice.actId ?? index} style={{ flexDirection: "row", alignItems: "flex-start", gap: 10 }}>
80
+ <View style={{ width: 62, paddingTop: 1, gap: 1 }}>
81
+ <View style={{ flexDirection: "row", alignItems: "center", gap: 5 }}>
82
+ <Text style={{ fontSize: 8.5, color: voice.glyph === "○" ? muted : palette.voice }}>{voice.glyph}</Text>
83
+ {voice.speaker && <Text numberOfLines={1} style={{ color: foreground, fontSize: 12.5, fontWeight: "500", flexShrink: 1 }}>{voice.speaker}</Text>}
84
+ </View>
85
+ {(voice.bell || voice.actId) && <Text numberOfLines={1} style={{ color: muted, fontSize: 10.5, fontFamily: MONO, paddingLeft: 13 }}>{detail}</Text>}
86
+ </View>
87
+ <View style={{ flex: 1, borderLeftWidth: StyleSheet.hairlineWidth, borderLeftColor: border, paddingLeft: 10 }}>
88
+ <SquareMarkdownBody text={block.body} theme={palette.theme} />
89
+ </View>
90
+ </View>
91
+ );
92
+ })}
93
+ </View>
94
+ ) : (
95
+ <SquareMarkdownBody text={blocks[0].body} theme={palette.theme} style={indent} />
96
+ )}
67
97
  </ScrollView>
68
98
  )}
69
99
  </View>
@@ -50,6 +50,21 @@ export function parseSquareBlocks(text: string): SquareBlock[] {
50
50
  return blocks;
51
51
  }
52
52
 
53
+ export interface SquareVoice { glyph: string; speaker?: string; bell: boolean; actId?: string }
54
+
55
+ // A voice line is chrome for the card: `● rei rang the bell` names who, how, and which
56
+ // activity. Route prose is parsed into those marks, never rendered as narration.
57
+ export function parseSquareVoice(actorLine?: string, metaLine?: string): SquareVoice {
58
+ const line = actorLine?.trim() ?? "";
59
+ const cleaned = line.replace(/^[●○◎◆■·]\s*/, "").trim();
60
+ return {
61
+ glyph: /^[●○◎·]/.exec(line)?.[0] ?? "●",
62
+ speaker: /\S+/.exec(cleaned)?.[0],
63
+ bell: /rang the bell/i.test(cleaned),
64
+ actId: /act\/\d+/.exec(metaLine ?? "")?.[0],
65
+ };
66
+ }
67
+
53
68
  export const squareCardDetailsSchema = z.object({
54
69
  fullText: z.string().optional(),
55
70
  items: z.array(z.object({
@@ -62,7 +77,7 @@ export const squareCardDetailsSchema = z.object({
62
77
  })).min(1),
63
78
  });
64
79
 
65
- /** Human-facing summary; the stored description is Square's agent voice, and the human is not the recipient. */
80
+ /** Human-facing summary of a route; the card itself leads with people, not this prose. */
66
81
  export function squareHeadline(item: z.output<typeof squareCardDetailsSchema>["items"][number]): string {
67
82
  if (item.actor === undefined || item.route === undefined) return item.description;
68
83
  const to = item.recipient;