@ctliz/agent-intercom-pi 0.11.0-connect.2

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,126 @@
1
+ import type { Component } from "@earendil-works/pi-tui";
2
+ import { truncateToWidth, visibleWidth, wrapTextWithAnsi } from "@earendil-works/pi-tui";
3
+ import type { Theme } from "@earendil-works/pi-coding-agent";
4
+ import type { SessionInfo, Message } from "../types.ts";
5
+ import { formatSessionDisplayName, sanitizeDisplayText } from "./session-identity.ts";
6
+ import { formatMessageTiming } from "./timestamps.ts";
7
+
8
+ export class InlineMessageComponent implements Component {
9
+ private from: SessionInfo;
10
+ private message: Message;
11
+ private theme: Theme;
12
+ private replyCommand?: string;
13
+ private bodyText?: string;
14
+ private collapsed: boolean;
15
+ private receivedAt?: number;
16
+ private readAt?: number;
17
+
18
+ constructor(
19
+ from: SessionInfo,
20
+ message: Message,
21
+ theme: Theme,
22
+ replyCommand?: string,
23
+ bodyText?: string,
24
+ collapsed = false,
25
+ receivedAt?: number,
26
+ readAt?: number,
27
+ ) {
28
+ this.from = from;
29
+ this.message = message;
30
+ this.theme = theme;
31
+ this.replyCommand = replyCommand;
32
+ this.bodyText = bodyText;
33
+ this.collapsed = collapsed;
34
+ this.receivedAt = receivedAt;
35
+ this.readAt = readAt;
36
+ }
37
+
38
+ invalidate(): void {}
39
+
40
+ render(width: number): string[] {
41
+ const lines: string[] = [];
42
+ const borderChar = "โ”€";
43
+ const senderName = formatSessionDisplayName(this.from, "Unknown sender");
44
+ const senderCwd = sanitizeDisplayText(this.from.cwd, "Unknown path");
45
+ if (width < 3) {
46
+ return [truncateToWidth(`From ${senderName}`, width)];
47
+ }
48
+ const bodyWidth = Math.max(1, width - 2);
49
+
50
+ const header = ` ๐Ÿ“จ From: ${senderName} (${senderCwd}) `;
51
+ const headerText = truncateToWidth(this.collapsed ? `${header} Ctrl+O expands ` : header, bodyWidth, "");
52
+ const headerPadding = Math.max(0, bodyWidth - visibleWidth(headerText));
53
+ lines.push(this.theme.fg("accent", `โ•ญ${headerText}${borderChar.repeat(headerPadding)}โ•ฎ`));
54
+
55
+ if (this.collapsed) {
56
+ const preview = (this.bodyText || this.message.content.text).replace(/\s+/g, " ").trim();
57
+ const previewText = truncateToWidth(preview, bodyWidth, "");
58
+ const previewPadding = Math.max(0, bodyWidth - visibleWidth(previewText));
59
+ lines.push(this.theme.fg("accent", `โ”‚${previewText}${" ".repeat(previewPadding)}โ”‚`));
60
+
61
+ const meta: string[] = [];
62
+ const timing = formatMessageTiming({ sentAt: this.message.timestamp, receivedAt: this.receivedAt, readAt: this.readAt });
63
+ if (timing) meta.push(timing);
64
+ if (this.replyCommand) meta.push(`โ†ฉ To reply: ${this.replyCommand}`);
65
+ if (this.message.content.attachments?.length) {
66
+ const count = this.message.content.attachments.length;
67
+ meta.push(`๐Ÿ“Ž ${count} attachment${count === 1 ? "" : "s"}`);
68
+ }
69
+ if (this.message.replyTo && !this.message.expectsReply) meta.push(`โ†ณ Reply to ${this.message.replyTo.slice(0, 8)}`);
70
+ meta.push("Ctrl+O to expand");
71
+
72
+ const metaText = truncateToWidth(this.theme.fg("dim", ` ${meta.join(" ยท ")}`), bodyWidth, "");
73
+ const metaPadding = Math.max(0, bodyWidth - visibleWidth(metaText));
74
+ lines.push(this.theme.fg("accent", `โ”‚${metaText}${" ".repeat(metaPadding)}โ”‚`));
75
+ lines.push(this.theme.fg("accent", `โ•ฐ${borderChar.repeat(bodyWidth)}โ•ฏ`));
76
+ return lines;
77
+ }
78
+
79
+ const contentLines = wrapTextWithAnsi(this.bodyText || this.message.content.text, bodyWidth);
80
+ for (const line of contentLines) {
81
+ const text = truncateToWidth(line, bodyWidth, "");
82
+ const padding = Math.max(0, bodyWidth - visibleWidth(text));
83
+ lines.push(this.theme.fg("accent", `โ”‚${text}${" ".repeat(padding)}โ”‚`));
84
+ }
85
+
86
+ if (this.replyCommand) {
87
+ lines.push(this.theme.fg("accent", `โ”‚${" ".repeat(bodyWidth)}โ”‚`));
88
+ const replyLines = wrapTextWithAnsi(this.theme.fg("dim", ` โ†ฉ To reply: ${this.replyCommand}`), bodyWidth);
89
+ for (const line of replyLines) {
90
+ const text = truncateToWidth(line, bodyWidth, "");
91
+ const padding = Math.max(0, bodyWidth - visibleWidth(text));
92
+ lines.push(this.theme.fg("accent", `โ”‚${text}${" ".repeat(padding)}โ”‚`));
93
+ }
94
+ }
95
+
96
+ if (this.message.content.attachments?.length) {
97
+ lines.push(this.theme.fg("accent", `โ”‚${" ".repeat(bodyWidth)}โ”‚`));
98
+ for (const att of this.message.content.attachments) {
99
+ const label = this.theme.fg("dim", ` ๐Ÿ“Ž ${sanitizeDisplayText(att.name, "Unnamed attachment")}`);
100
+ const text = truncateToWidth(label, bodyWidth, "");
101
+ const padding = Math.max(0, bodyWidth - visibleWidth(text));
102
+ lines.push(this.theme.fg("accent", `โ”‚${text}${" ".repeat(padding)}โ”‚`));
103
+ }
104
+ }
105
+
106
+ if (this.message.replyTo && !this.message.expectsReply) {
107
+ lines.push(this.theme.fg("accent", `โ”‚${" ".repeat(bodyWidth)}โ”‚`));
108
+ const reply = this.theme.fg("dim", ` โ†ณ Reply to ${this.message.replyTo.slice(0, 8)}`);
109
+ const text = truncateToWidth(reply, bodyWidth, "");
110
+ const padding = Math.max(0, bodyWidth - visibleWidth(text));
111
+ lines.push(this.theme.fg("accent", `โ”‚${text}${" ".repeat(padding)}โ”‚`));
112
+ }
113
+
114
+ const timing = formatMessageTiming({ sentAt: this.message.timestamp, receivedAt: this.receivedAt, readAt: this.readAt });
115
+ if (timing) {
116
+ lines.push(this.theme.fg("accent", `โ”‚${" ".repeat(bodyWidth)}โ”‚`));
117
+ const timingText = truncateToWidth(this.theme.fg("dim", ` ${timing}`), bodyWidth, "");
118
+ const timingPadding = Math.max(0, bodyWidth - visibleWidth(timingText));
119
+ lines.push(this.theme.fg("accent", `โ”‚${timingText}${" ".repeat(timingPadding)}โ”‚`));
120
+ }
121
+
122
+ lines.push(this.theme.fg("accent", `โ•ฐ${borderChar.repeat(bodyWidth)}โ•ฏ`));
123
+
124
+ return lines;
125
+ }
126
+ }
@@ -0,0 +1,55 @@
1
+ import type { SessionInfo } from "../types.ts";
2
+
3
+ const ANSI_SEQUENCE = /[\u001b\u009b](?:\][^\u0007]*(?:\u0007|\u001b\\)|\[[0-?]*[ -/]*[@-~]|[@-_])/g;
4
+ const CONTROL_CHARACTER = /[\u0000-\u001f\u007f-\u009f]/g;
5
+
6
+ /** Make untrusted presence metadata safe to place directly in a terminal UI. */
7
+ export function sanitizeDisplayText(value: string | undefined, fallback = ""): string {
8
+ const sanitized = (value ?? "")
9
+ .replace(ANSI_SEQUENCE, "")
10
+ .replace(CONTROL_CHARACTER, " ")
11
+ .replace(/\s+/g, " ")
12
+ .trim();
13
+ return sanitized || fallback;
14
+ }
15
+
16
+ /**
17
+ * Return the shortest prefix which distinguishes every ID in the collection.
18
+ * A small floor keeps isolated IDs useful to humans while still expanding
19
+ * automatically when UUIDv7 values share a long timestamp prefix.
20
+ */
21
+ export function shortestUniqueIdPrefixes(ids: readonly string[], minimumLength = 4): Map<string, string> {
22
+ const uniqueIds = [...new Set(ids)];
23
+ const result = new Map<string, string>();
24
+
25
+ for (const id of uniqueIds) {
26
+ let length = Math.min(Math.max(1, minimumLength), id.length);
27
+ while (
28
+ length < id.length
29
+ && uniqueIds.some(other => other !== id && other.startsWith(id.slice(0, length)))
30
+ ) {
31
+ length += 1;
32
+ }
33
+ result.set(id, id.slice(0, length));
34
+ }
35
+
36
+ return result;
37
+ }
38
+
39
+ export function sessionOriginLabel(session: SessionInfo): string | undefined {
40
+ if (session.origin !== "remote") return undefined;
41
+ return `remote:${sanitizeDisplayText(session.remoteHostId, "unknown-host")}`;
42
+ }
43
+
44
+ export function formatSessionDisplayName(session: SessionInfo, fallback?: string): string {
45
+ const name = sanitizeDisplayText(session.name, fallback ?? sanitizeDisplayText(session.id.slice(0, 8), "Unknown session"));
46
+ const origin = sessionOriginLabel(session);
47
+ return origin ? `${name} [${origin}]` : name;
48
+ }
49
+
50
+ export function searchableSessionText(session: SessionInfo): string {
51
+ return [session.name, session.id, session.cwd, session.model, session.status, session.origin, session.remoteHostId, session.parentSessionId, session.rootSessionId]
52
+ .filter((value): value is string => typeof value === "string")
53
+ .join("\n")
54
+ .toLocaleLowerCase();
55
+ }
@@ -0,0 +1,205 @@
1
+ import type { Component } from "@earendil-works/pi-tui";
2
+ import { truncateToWidth, visibleWidth } from "@earendil-works/pi-tui";
3
+ import type { KeybindingsManager, Theme } from "@earendil-works/pi-coding-agent";
4
+ import type { SessionInfo } from "../types.ts";
5
+ import { sanitizeDisplayText, searchableSessionText, sessionOriginLabel, shortestUniqueIdPrefixes } from "./session-identity.ts";
6
+
7
+ function middleTruncate(text: string, maxWidth: number): string {
8
+ if (visibleWidth(text) <= maxWidth) {
9
+ return text;
10
+ }
11
+ if (maxWidth <= 3) {
12
+ return truncateToWidth(text, maxWidth, "");
13
+ }
14
+
15
+ const chars = [...text];
16
+ const targetSideWidth = Math.max(1, Math.floor((maxWidth - 1) / 2));
17
+
18
+ let left = "";
19
+ for (const char of chars) {
20
+ if (visibleWidth(left + char) > targetSideWidth) break;
21
+ left += char;
22
+ }
23
+
24
+ let right = "";
25
+ for (const char of chars.slice().reverse()) {
26
+ if (visibleWidth(char + right) > targetSideWidth) break;
27
+ right = char + right;
28
+ }
29
+
30
+ return truncateToWidth(`${left}โ€ฆ${right}`, maxWidth, "");
31
+ }
32
+
33
+ function sessionTitle(
34
+ session: SessionInfo,
35
+ idPrefix: string,
36
+ options?: { self?: boolean; sameCwd?: boolean },
37
+ ): string {
38
+ const name = sanitizeDisplayText(session.name, "Unnamed session");
39
+ const tags = [options?.self ? "self" : undefined, options?.sameCwd ? "same cwd" : undefined, sessionOriginLabel(session)]
40
+ .filter((tag): tag is string => Boolean(tag));
41
+ const suffix = tags.length ? ` [${tags.join(", ")}]` : "";
42
+ return `${name} (${sanitizeDisplayText(idPrefix, "unknown")})${suffix}`;
43
+ }
44
+
45
+ export class SessionListOverlay implements Component {
46
+ private theme: Theme;
47
+ private keybindings: KeybindingsManager;
48
+ private currentSession: SessionInfo;
49
+ private done: (result: SessionInfo | undefined) => void;
50
+ private sessions: SessionInfo[];
51
+ private selectedIndex = 0;
52
+ private maxVisible = 8;
53
+ private query = "";
54
+ private idPrefixes: Map<string, string>;
55
+
56
+ constructor(
57
+ theme: Theme,
58
+ keybindings: KeybindingsManager,
59
+ currentSession: SessionInfo,
60
+ sessions: SessionInfo[],
61
+ done: (result: SessionInfo | undefined) => void,
62
+ ) {
63
+ this.theme = theme;
64
+ this.keybindings = keybindings;
65
+ this.currentSession = currentSession;
66
+ this.sessions = sessions;
67
+ this.done = done;
68
+ this.idPrefixes = shortestUniqueIdPrefixes([currentSession.id, ...sessions.map(session => session.id)]);
69
+ }
70
+
71
+ private get filteredSessions(): SessionInfo[] {
72
+ const query = this.query.trim().toLocaleLowerCase();
73
+ if (!query) return this.sessions;
74
+ return this.sessions.filter(session => searchableSessionText(session).includes(query));
75
+ }
76
+
77
+ private onSessionSelect(sessionId: string, sessions: SessionInfo[]): void {
78
+ const session = sessions.find(s => s.id === sessionId);
79
+ if (!session) return;
80
+ this.done(session);
81
+ }
82
+
83
+ invalidate(): void {}
84
+
85
+ handleInput(data: string): void {
86
+ if (this.keybindings.matches(data, "tui.select.cancel")) {
87
+ this.done(undefined);
88
+ return;
89
+ }
90
+
91
+ const sessions = this.filteredSessions;
92
+
93
+ if (this.keybindings.matches(data, "tui.select.up")) {
94
+ if (sessions.length === 0) return;
95
+ this.selectedIndex = this.selectedIndex === 0 ? sessions.length - 1 : this.selectedIndex - 1;
96
+ return;
97
+ }
98
+
99
+ if (this.keybindings.matches(data, "tui.select.down")) {
100
+ if (sessions.length === 0) return;
101
+ this.selectedIndex = this.selectedIndex === sessions.length - 1 ? 0 : this.selectedIndex + 1;
102
+ return;
103
+ }
104
+
105
+ if (this.keybindings.matches(data, "tui.select.confirm")) {
106
+ const session = sessions[this.selectedIndex];
107
+ if (session) {
108
+ this.onSessionSelect(session.id, sessions);
109
+ }
110
+ return;
111
+ }
112
+
113
+ if (this.keybindings.matches(data, "tui.editor.deleteCharBackward")) {
114
+ this.query = [...this.query].slice(0, -1).join("");
115
+ this.selectedIndex = 0;
116
+ return;
117
+ }
118
+
119
+ if (!data.startsWith("\x1b")) {
120
+ const printable = [...data].filter(character => {
121
+ const code = character.charCodeAt(0);
122
+ return code >= 32 && code !== 127 && !(code >= 128 && code <= 159);
123
+ }).join("");
124
+ if (printable) {
125
+ this.query += printable;
126
+ this.selectedIndex = 0;
127
+ }
128
+ }
129
+ }
130
+
131
+ render(width: number): string[] {
132
+ const innerWidth = Math.max(1, Math.min(width, 88));
133
+ if (innerWidth === 1) {
134
+ return [this.theme.fg("accent", "โ”‚")];
135
+ }
136
+
137
+ const contentWidth = Math.max(0, innerWidth - 2);
138
+ const footer = `Type: Filter โ€ข ${this.keybindings.getKeys("tui.select.confirm").join("/")}: Message โ€ข ${this.keybindings.getKeys("tui.select.cancel").join("/")}: Close`;
139
+ const border = (text: string) => this.theme.fg("accent", text);
140
+ const row = (text = "") => {
141
+ const clipped = truncateToWidth(text, contentWidth, "", true);
142
+ return `${border("โ”‚")}${clipped}${" ".repeat(Math.max(0, contentWidth - visibleWidth(clipped)))}${border("โ”‚")}`;
143
+ };
144
+
145
+ const lines: string[] = [];
146
+ const sessions = this.filteredSessions;
147
+ if (this.selectedIndex >= sessions.length) this.selectedIndex = Math.max(0, sessions.length - 1);
148
+ const currentIdPrefix = this.idPrefixes.get(this.currentSession.id) ?? this.currentSession.id;
149
+ const currentCwd = sanitizeDisplayText(this.currentSession.cwd, "Unknown path");
150
+ const currentModel = sanitizeDisplayText(this.currentSession.model, "Unknown model");
151
+ lines.push(border(`โ•ญ${"โ”€".repeat(contentWidth)}โ•ฎ`));
152
+ lines.push(row(this.theme.bold(" Current Session")));
153
+ lines.push(border(`โ”œ${"โ”€".repeat(contentWidth)}โ”ค`));
154
+ lines.push(row());
155
+ lines.push(row(` ${this.theme.fg("dim", sessionTitle(this.currentSession, currentIdPrefix, { self: true }))}`));
156
+ lines.push(row(` ${this.theme.fg("dim", `${middleTruncate(currentCwd, Math.max(8, contentWidth - 4))} โ€ข ${currentModel}`)}`));
157
+ lines.push(row());
158
+ lines.push(border(`โ”œ${"โ”€".repeat(contentWidth)}โ”ค`));
159
+ lines.push(row(this.theme.bold(" Other Sessions")));
160
+ lines.push(row(this.theme.fg("dim", ` Search: ${sanitizeDisplayText(this.query)}โ–ˆ`)));
161
+ lines.push(row());
162
+
163
+ if (sessions.length === 0) {
164
+ lines.push(row(this.theme.fg("dim", this.sessions.length === 0
165
+ ? " No other intercom-connected sessions"
166
+ : ` No sessions match โ€œ${sanitizeDisplayText(this.query)}โ€`)));
167
+ } else {
168
+ const startIndex = Math.max(
169
+ 0,
170
+ Math.min(this.selectedIndex - Math.floor(this.maxVisible / 2), sessions.length - this.maxVisible),
171
+ );
172
+ const endIndex = Math.min(startIndex + this.maxVisible, sessions.length);
173
+
174
+ for (let index = startIndex; index < endIndex; index += 1) {
175
+ const session = sessions[index];
176
+ const isSelected = index === this.selectedIndex;
177
+ const sameCwd = session.cwd === this.currentSession.cwd;
178
+ const prefix = isSelected ? this.theme.fg("accent", "โ†’ ") : " ";
179
+ const idPrefix = this.idPrefixes.get(session.id) ?? session.id;
180
+ const title = sessionTitle(session, idPrefix, { sameCwd });
181
+ const cwd = sanitizeDisplayText(session.cwd, "Unknown path");
182
+ const model = sanitizeDisplayText(session.model, "Unknown model");
183
+ const pathText = `${middleTruncate(cwd, Math.max(8, contentWidth - 4))} โ€ข ${model}`;
184
+
185
+ lines.push(row(`${prefix}${isSelected ? this.theme.fg("accent", title) : title}`));
186
+ lines.push(row(` ${this.theme.fg("dim", pathText)}`));
187
+ if (index < endIndex - 1) {
188
+ lines.push(row());
189
+ }
190
+ }
191
+
192
+ if (startIndex > 0 || endIndex < sessions.length) {
193
+ lines.push(row());
194
+ lines.push(row(this.theme.fg("dim", ` ${this.selectedIndex + 1}/${sessions.length}`)));
195
+ }
196
+ }
197
+
198
+ lines.push(row());
199
+ lines.push(border(`โ”œ${"โ”€".repeat(contentWidth)}โ”ค`));
200
+ lines.push(row(this.theme.fg("dim", ` ${footer}`)));
201
+ lines.push(border(`โ•ฐ${"โ”€".repeat(contentWidth)}โ•ฏ`));
202
+
203
+ return lines;
204
+ }
205
+ }
@@ -0,0 +1,50 @@
1
+ export interface MessageTiming {
2
+ sentAt?: number;
3
+ receivedAt?: number;
4
+ readAt?: number;
5
+ deliveredAt?: number;
6
+ }
7
+
8
+ function validTimestamp(value: number | undefined): value is number {
9
+ return typeof value === "number" && Number.isFinite(value);
10
+ }
11
+
12
+ /** Format a timestamp in the user's local timezone for compact TUI rows. */
13
+ export function formatClockTime(timestamp: number): string {
14
+ const date = new Date(timestamp);
15
+ const pad = (value: number, width = 2) => String(value).padStart(width, "0");
16
+ return `${pad(date.getHours())}:${pad(date.getMinutes())}:${pad(date.getSeconds())}.${pad(date.getMilliseconds(), 3)}`;
17
+ }
18
+
19
+ /** Format a signed elapsed duration without hiding clock skew. */
20
+ export function formatTimeDelta(durationMs: number): string {
21
+ const sign = durationMs < 0 ? "โˆ’" : "+";
22
+ const absolute = Math.abs(durationMs);
23
+ if (absolute < 1000) return `${sign}${Math.round(absolute)}ms`;
24
+ if (absolute < 60_000) return `${sign}${(absolute / 1000).toFixed(absolute < 10_000 ? 2 : 1)}s`;
25
+ const minutes = Math.floor(absolute / 60_000);
26
+ const seconds = Math.floor((absolute % 60_000) / 1000);
27
+ return `${sign}${minutes}m${String(seconds).padStart(2, "0")}s`;
28
+ }
29
+
30
+ /**
31
+ * Build compact message timing text. Each parenthesized delta is measured from
32
+ * the immediately preceding timestamp.
33
+ */
34
+ export function formatMessageTiming(timing: MessageTiming): string {
35
+ const parts: string[] = [];
36
+ let previous: number | undefined;
37
+
38
+ const add = (label: string, timestamp: number | undefined) => {
39
+ if (!validTimestamp(timestamp)) return;
40
+ const delta = previous === undefined ? "" : ` (${formatTimeDelta(timestamp - previous)})`;
41
+ parts.push(`${label} ${formatClockTime(timestamp)}${delta}`);
42
+ previous = timestamp;
43
+ };
44
+
45
+ add("sent", timing.sentAt);
46
+ add("received", timing.receivedAt);
47
+ add("delivered", timing.deliveredAt);
48
+ add("read", timing.readAt);
49
+ return parts.join(" ยท ");
50
+ }