pi-gang 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,139 @@
1
+ import type { Component, TUI } 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 { IntercomClient } from "../broker/client.js";
5
+ import type { SessionInfo } from "../types.js";
6
+
7
+ export interface ComposeResult {
8
+ sent: boolean;
9
+ messageId?: string;
10
+ text?: string;
11
+ }
12
+
13
+ export class ComposeOverlay implements Component {
14
+ private tui: TUI;
15
+ private theme: Theme;
16
+ private keybindings: KeybindingsManager;
17
+ private target: SessionInfo;
18
+ private targetLabel: string;
19
+ private client: IntercomClient;
20
+ private done: (result: ComposeResult) => void;
21
+ private inputBuffer: string = "";
22
+ private sending: boolean = false;
23
+ private error: string | null = null;
24
+
25
+ constructor(
26
+ tui: TUI,
27
+ theme: Theme,
28
+ keybindings: KeybindingsManager,
29
+ target: SessionInfo,
30
+ targetLabel: string,
31
+ client: IntercomClient,
32
+ done: (result: ComposeResult) => void,
33
+ ) {
34
+ this.tui = tui;
35
+ this.theme = theme;
36
+ this.keybindings = keybindings;
37
+ this.target = target;
38
+ this.targetLabel = targetLabel;
39
+ this.client = client;
40
+ this.done = done;
41
+ }
42
+
43
+ invalidate(): void {}
44
+
45
+ handleInput(data: string): void {
46
+ if (this.sending) return;
47
+ if (this.keybindings.matches(data, "tui.select.cancel")) {
48
+ this.done({ sent: false });
49
+ return;
50
+ }
51
+
52
+ if (data.startsWith("\x1b")) {
53
+ return;
54
+ }
55
+
56
+ if (this.keybindings.matches(data, "tui.select.confirm")) {
57
+ if (this.inputBuffer.trim()) {
58
+ this.sendMessage();
59
+ }
60
+ return;
61
+ }
62
+
63
+ if (this.keybindings.matches(data, "tui.editor.deleteCharBackward")) {
64
+ this.inputBuffer = [...this.inputBuffer].slice(0, -1).join("");
65
+ this.tui.requestRender();
66
+ return;
67
+ }
68
+
69
+ const printable = [...data].filter(c => c >= " ").join("");
70
+ if (printable) {
71
+ this.inputBuffer += printable;
72
+ this.tui.requestRender();
73
+ }
74
+ }
75
+
76
+ private async sendMessage(): Promise<void> {
77
+ this.sending = true;
78
+ this.error = null;
79
+ this.tui.requestRender();
80
+
81
+ try {
82
+ const result = await this.client.send(this.target.id, {
83
+ text: this.inputBuffer.trim(),
84
+ });
85
+
86
+ if (!result.delivered) {
87
+ this.error = result.reason ?? "Message not delivered. Session may not exist or has disconnected.";
88
+ this.sending = false;
89
+ this.tui.requestRender();
90
+ return;
91
+ }
92
+
93
+ this.done({
94
+ sent: true,
95
+ messageId: result.id,
96
+ text: this.inputBuffer.trim(),
97
+ });
98
+ } catch (error) {
99
+ this.error = error instanceof Error ? error.message : String(error);
100
+ this.sending = false;
101
+ this.tui.requestRender();
102
+ }
103
+ }
104
+
105
+ render(width: number): string[] {
106
+ const innerWidth = Math.max(24, Math.min(width - 2, 72));
107
+ const contentWidth = Math.max(1, innerWidth - 2);
108
+ const footer = `${this.keybindings.getKeys("tui.select.confirm").join("/")}: Send • ${this.keybindings.getKeys("tui.select.cancel").join("/")}: Close`;
109
+ const border = (text: string) => this.theme.fg("accent", text);
110
+ const row = (text = "") => {
111
+ const clipped = truncateToWidth(text, contentWidth, "", true);
112
+ return `${border("│")}${clipped}${" ".repeat(Math.max(0, contentWidth - visibleWidth(clipped)))}${border("│")}`;
113
+ };
114
+
115
+ const lines: string[] = [];
116
+ lines.push(border(`╭${"─".repeat(contentWidth)}╮`));
117
+ lines.push(row(this.theme.bold(` Send to: ${this.targetLabel}`)));
118
+ lines.push(row(this.theme.fg("dim", ` ${this.target.cwd} • ${this.target.model}`)));
119
+ lines.push(border(`├${"─".repeat(contentWidth)}┤`));
120
+ lines.push(row());
121
+
122
+ if (this.sending) {
123
+ lines.push(row(this.theme.fg("dim", " Sending...")));
124
+ } else if (this.error) {
125
+ lines.push(row(this.theme.fg("error", ` Error: ${this.error}`)));
126
+ lines.push(row());
127
+ lines.push(row(` > ${this.inputBuffer}█`));
128
+ } else {
129
+ lines.push(row(` > ${this.inputBuffer}█`));
130
+ }
131
+
132
+ lines.push(row());
133
+ lines.push(border(`├${"─".repeat(contentWidth)}┤`));
134
+ lines.push(row(this.theme.fg("dim", ` ${footer}`)));
135
+ lines.push(border(`╰${"─".repeat(contentWidth)}╯`));
136
+
137
+ return lines;
138
+ }
139
+ }
@@ -0,0 +1,78 @@
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.js";
5
+
6
+ type InlineMessageTheme = Pick<Theme, "fg">;
7
+
8
+ export class InlineMessageComponent implements Component {
9
+ private from: SessionInfo;
10
+ private message: Message;
11
+ private theme: InlineMessageTheme;
12
+ private replyCommand?: string;
13
+ private bodyText?: string;
14
+
15
+ constructor(from: SessionInfo, message: Message, theme: InlineMessageTheme, replyCommand?: string, bodyText?: string) {
16
+ this.from = from;
17
+ this.message = message;
18
+ this.theme = theme;
19
+ this.replyCommand = replyCommand;
20
+ this.bodyText = bodyText;
21
+ }
22
+
23
+ invalidate(): void {}
24
+
25
+ render(width: number): string[] {
26
+ const lines: string[] = [];
27
+ const borderChar = "─";
28
+ if (width < 3) {
29
+ return [truncateToWidth(`From ${this.from.name || this.from.id.slice(0, 8)}`, width)];
30
+ }
31
+ const bodyWidth = Math.max(1, width - 2);
32
+
33
+ const senderName = this.from.name || this.from.id.slice(0, 8);
34
+ const header = ` 📨 From: ${senderName} (${this.from.cwd}) `;
35
+ const headerText = truncateToWidth(header, bodyWidth, "");
36
+ const headerPadding = Math.max(0, bodyWidth - visibleWidth(headerText));
37
+ lines.push(this.theme.fg("accent", `╭${headerText}${borderChar.repeat(headerPadding)}╮`));
38
+
39
+ const contentLines = wrapTextWithAnsi(this.bodyText || this.message.content.text, bodyWidth);
40
+ for (const line of contentLines) {
41
+ const text = truncateToWidth(line, bodyWidth, "");
42
+ const padding = Math.max(0, bodyWidth - visibleWidth(text));
43
+ lines.push(this.theme.fg("accent", `│${text}${" ".repeat(padding)}│`));
44
+ }
45
+
46
+ if (this.replyCommand) {
47
+ lines.push(this.theme.fg("accent", `│${" ".repeat(bodyWidth)}│`));
48
+ const replyLines = wrapTextWithAnsi(this.theme.fg("dim", ` ↩ To reply: ${this.replyCommand}`), bodyWidth);
49
+ for (const line of replyLines) {
50
+ const text = truncateToWidth(line, bodyWidth, "");
51
+ const padding = Math.max(0, bodyWidth - visibleWidth(text));
52
+ lines.push(this.theme.fg("accent", `│${text}${" ".repeat(padding)}│`));
53
+ }
54
+ }
55
+
56
+ if (this.message.content.attachments?.length) {
57
+ lines.push(this.theme.fg("accent", `│${" ".repeat(bodyWidth)}│`));
58
+ for (const att of this.message.content.attachments) {
59
+ const label = this.theme.fg("dim", ` 📎 ${att.name}`);
60
+ const text = truncateToWidth(label, bodyWidth, "");
61
+ const padding = Math.max(0, bodyWidth - visibleWidth(text));
62
+ lines.push(this.theme.fg("accent", `│${text}${" ".repeat(padding)}│`));
63
+ }
64
+ }
65
+
66
+ if (this.message.replyTo && !this.message.expectsReply) {
67
+ lines.push(this.theme.fg("accent", `│${" ".repeat(bodyWidth)}│`));
68
+ const reply = this.theme.fg("dim", ` ↳ Reply to ${this.message.replyTo.slice(0, 8)}`);
69
+ const text = truncateToWidth(reply, bodyWidth, "");
70
+ const padding = Math.max(0, bodyWidth - visibleWidth(text));
71
+ lines.push(this.theme.fg("accent", `│${text}${" ".repeat(padding)}│`));
72
+ }
73
+
74
+ lines.push(this.theme.fg("accent", `╰${borderChar.repeat(bodyWidth)}╯`));
75
+
76
+ return lines;
77
+ }
78
+ }
@@ -0,0 +1,162 @@
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.js";
5
+
6
+ function middleTruncate(text: string, maxWidth: number): string {
7
+ if (visibleWidth(text) <= maxWidth) {
8
+ return text;
9
+ }
10
+ if (maxWidth <= 3) {
11
+ return truncateToWidth(text, maxWidth, "");
12
+ }
13
+
14
+ const chars = [...text];
15
+ const targetSideWidth = Math.max(1, Math.floor((maxWidth - 1) / 2));
16
+
17
+ let left = "";
18
+ for (const char of chars) {
19
+ if (visibleWidth(left + char) > targetSideWidth) break;
20
+ left += char;
21
+ }
22
+
23
+ let right = "";
24
+ for (const char of chars.slice().reverse()) {
25
+ if (visibleWidth(char + right) > targetSideWidth) break;
26
+ right = char + right;
27
+ }
28
+
29
+ return truncateToWidth(`${left}…${right}`, maxWidth, "");
30
+ }
31
+
32
+ function shortSessionId(sessionId: string): string {
33
+ return sessionId.slice(0, 8);
34
+ }
35
+
36
+ function sessionTitle(session: SessionInfo, options?: { self?: boolean; sameCwd?: boolean }): string {
37
+ const name = session.name || "Unnamed session";
38
+ const tags = [options?.self ? "self" : undefined, options?.sameCwd ? "same cwd" : undefined]
39
+ .filter((tag): tag is string => Boolean(tag));
40
+ const suffix = tags.length ? ` [${tags.join(", ")}]` : "";
41
+ return `${name} (${shortSessionId(session.id)})${suffix}`;
42
+ }
43
+
44
+ export class SessionListOverlay implements Component {
45
+ private theme: Theme;
46
+ private keybindings: KeybindingsManager;
47
+ private currentSession: SessionInfo;
48
+ private done: (result: SessionInfo | undefined) => void;
49
+ private sessions: SessionInfo[];
50
+ private selectedIndex = 0;
51
+ private maxVisible = 8;
52
+
53
+ constructor(
54
+ theme: Theme,
55
+ keybindings: KeybindingsManager,
56
+ currentSession: SessionInfo,
57
+ sessions: SessionInfo[],
58
+ done: (result: SessionInfo | undefined) => void,
59
+ ) {
60
+ this.theme = theme;
61
+ this.keybindings = keybindings;
62
+ this.currentSession = currentSession;
63
+ this.sessions = sessions;
64
+ this.done = done;
65
+ }
66
+
67
+ private onSessionSelect(sessionId: string): void {
68
+ const session = this.sessions.find(s => s.id === sessionId);
69
+ if (!session) return;
70
+ this.done(session);
71
+ }
72
+
73
+ invalidate(): void {}
74
+
75
+ handleInput(data: string): void {
76
+ if (this.keybindings.matches(data, "tui.select.cancel")) {
77
+ this.done(undefined);
78
+ return;
79
+ }
80
+
81
+ if (this.sessions.length === 0) {
82
+ return;
83
+ }
84
+
85
+ if (this.keybindings.matches(data, "tui.select.up")) {
86
+ this.selectedIndex = this.selectedIndex === 0 ? this.sessions.length - 1 : this.selectedIndex - 1;
87
+ return;
88
+ }
89
+
90
+ if (this.keybindings.matches(data, "tui.select.down")) {
91
+ this.selectedIndex = this.selectedIndex === this.sessions.length - 1 ? 0 : this.selectedIndex + 1;
92
+ return;
93
+ }
94
+
95
+ if (this.keybindings.matches(data, "tui.select.confirm")) {
96
+ const session = this.sessions[this.selectedIndex];
97
+ if (session) {
98
+ this.onSessionSelect(session.id);
99
+ }
100
+ }
101
+ }
102
+
103
+ render(width: number): string[] {
104
+ const innerWidth = Math.max(36, Math.min(width - 2, 88));
105
+ const contentWidth = Math.max(1, innerWidth - 2);
106
+ const footer = `${this.keybindings.getKeys("tui.select.confirm").join("/")}: Message • ${this.keybindings.getKeys("tui.select.cancel").join("/")}: Close`;
107
+ const border = (text: string) => this.theme.fg("accent", text);
108
+ const row = (text = "") => {
109
+ const clipped = truncateToWidth(text, contentWidth, "", true);
110
+ return `${border("│")}${clipped}${" ".repeat(Math.max(0, contentWidth - visibleWidth(clipped)))}${border("│")}`;
111
+ };
112
+
113
+ const lines: string[] = [];
114
+ lines.push(border(`╭${"─".repeat(contentWidth)}╮`));
115
+ lines.push(row(this.theme.bold(" Current Session")));
116
+ lines.push(border(`├${"─".repeat(contentWidth)}┤`));
117
+ lines.push(row());
118
+ lines.push(row(` ${this.theme.fg("dim", sessionTitle(this.currentSession, { self: true }))}`));
119
+ lines.push(row(` ${this.theme.fg("dim", `${middleTruncate(this.currentSession.cwd, Math.max(8, contentWidth - 4))} • ${this.currentSession.model}`)}`));
120
+ lines.push(row());
121
+ lines.push(border(`├${"─".repeat(contentWidth)}┤`));
122
+ lines.push(row(this.theme.bold(" Other Sessions")));
123
+ lines.push(row());
124
+
125
+ if (this.sessions.length === 0) {
126
+ lines.push(row(this.theme.fg("dim", " No other intercom-connected sessions")));
127
+ } else {
128
+ const startIndex = Math.max(
129
+ 0,
130
+ Math.min(this.selectedIndex - Math.floor(this.maxVisible / 2), this.sessions.length - this.maxVisible),
131
+ );
132
+ const endIndex = Math.min(startIndex + this.maxVisible, this.sessions.length);
133
+
134
+ for (let index = startIndex; index < endIndex; index += 1) {
135
+ const session = this.sessions[index];
136
+ const isSelected = index === this.selectedIndex;
137
+ const sameCwd = session.cwd === this.currentSession.cwd;
138
+ const prefix = isSelected ? this.theme.fg("accent", "→ ") : " ";
139
+ const title = sessionTitle(session, { sameCwd });
140
+ const pathText = `${middleTruncate(session.cwd, Math.max(8, contentWidth - 4))} • ${session.model}`;
141
+
142
+ lines.push(row(`${prefix}${isSelected ? this.theme.fg("accent", title) : title}`));
143
+ lines.push(row(` ${this.theme.fg("dim", pathText)}`));
144
+ if (index < endIndex - 1) {
145
+ lines.push(row());
146
+ }
147
+ }
148
+
149
+ if (startIndex > 0 || endIndex < this.sessions.length) {
150
+ lines.push(row());
151
+ lines.push(row(this.theme.fg("dim", ` ${this.selectedIndex + 1}/${this.sessions.length}`)));
152
+ }
153
+ }
154
+
155
+ lines.push(row());
156
+ lines.push(border(`├${"─".repeat(contentWidth)}┤`));
157
+ lines.push(row(this.theme.fg("dim", ` ${footer}`)));
158
+ lines.push(border(`╰${"─".repeat(contentWidth)}╯`));
159
+
160
+ return lines;
161
+ }
162
+ }