Add 5 pi extensions: pi-subagents, pi-crew, rpiv-pi, pi-interactive-shell, pi-intercom
This commit is contained in:
139
extensions/pi-intercom/ui/compose.ts
Normal file
139
extensions/pi-intercom/ui/compose.ts
Normal file
@@ -0,0 +1,139 @@
|
||||
import type { Component, TUI } from "@mariozechner/pi-tui";
|
||||
import { truncateToWidth, visibleWidth } from "@mariozechner/pi-tui";
|
||||
import type { KeybindingsManager, Theme } from "@mariozechner/pi-coding-agent";
|
||||
import type { IntercomClient } from "../broker/client.js";
|
||||
import type { SessionInfo } from "../types.js";
|
||||
|
||||
export interface ComposeResult {
|
||||
sent: boolean;
|
||||
messageId?: string;
|
||||
text?: string;
|
||||
}
|
||||
|
||||
export class ComposeOverlay implements Component {
|
||||
private tui: TUI;
|
||||
private theme: Theme;
|
||||
private keybindings: KeybindingsManager;
|
||||
private target: SessionInfo;
|
||||
private targetLabel: string;
|
||||
private client: IntercomClient;
|
||||
private done: (result: ComposeResult) => void;
|
||||
private inputBuffer: string = "";
|
||||
private sending: boolean = false;
|
||||
private error: string | null = null;
|
||||
|
||||
constructor(
|
||||
tui: TUI,
|
||||
theme: Theme,
|
||||
keybindings: KeybindingsManager,
|
||||
target: SessionInfo,
|
||||
targetLabel: string,
|
||||
client: IntercomClient,
|
||||
done: (result: ComposeResult) => void,
|
||||
) {
|
||||
this.tui = tui;
|
||||
this.theme = theme;
|
||||
this.keybindings = keybindings;
|
||||
this.target = target;
|
||||
this.targetLabel = targetLabel;
|
||||
this.client = client;
|
||||
this.done = done;
|
||||
}
|
||||
|
||||
invalidate(): void {}
|
||||
|
||||
handleInput(data: string): void {
|
||||
if (this.sending) return;
|
||||
if (this.keybindings.matches(data, "tui.select.cancel")) {
|
||||
this.done({ sent: false });
|
||||
return;
|
||||
}
|
||||
|
||||
if (data.startsWith("\x1b")) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (this.keybindings.matches(data, "tui.select.confirm")) {
|
||||
if (this.inputBuffer.trim()) {
|
||||
this.sendMessage();
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (this.keybindings.matches(data, "tui.editor.deleteCharBackward")) {
|
||||
this.inputBuffer = [...this.inputBuffer].slice(0, -1).join("");
|
||||
this.tui.requestRender();
|
||||
return;
|
||||
}
|
||||
|
||||
const printable = [...data].filter(c => c >= " ").join("");
|
||||
if (printable) {
|
||||
this.inputBuffer += printable;
|
||||
this.tui.requestRender();
|
||||
}
|
||||
}
|
||||
|
||||
private async sendMessage(): Promise<void> {
|
||||
this.sending = true;
|
||||
this.error = null;
|
||||
this.tui.requestRender();
|
||||
|
||||
try {
|
||||
const result = await this.client.send(this.target.id, {
|
||||
text: this.inputBuffer.trim(),
|
||||
});
|
||||
|
||||
if (!result.delivered) {
|
||||
this.error = result.reason ?? "Message not delivered. Session may not exist or has disconnected.";
|
||||
this.sending = false;
|
||||
this.tui.requestRender();
|
||||
return;
|
||||
}
|
||||
|
||||
this.done({
|
||||
sent: true,
|
||||
messageId: result.id,
|
||||
text: this.inputBuffer.trim(),
|
||||
});
|
||||
} catch (error) {
|
||||
this.error = error instanceof Error ? error.message : String(error);
|
||||
this.sending = false;
|
||||
this.tui.requestRender();
|
||||
}
|
||||
}
|
||||
|
||||
render(width: number): string[] {
|
||||
const innerWidth = Math.max(24, Math.min(width - 2, 72));
|
||||
const contentWidth = Math.max(1, innerWidth - 2);
|
||||
const footer = `${this.keybindings.getKeys("tui.select.confirm").join("/")}: Send • ${this.keybindings.getKeys("tui.select.cancel").join("/")}: Close`;
|
||||
const border = (text: string) => this.theme.fg("accent", text);
|
||||
const row = (text = "") => {
|
||||
const clipped = truncateToWidth(text, contentWidth, "", true);
|
||||
return `${border("│")}${clipped}${" ".repeat(Math.max(0, contentWidth - visibleWidth(clipped)))}${border("│")}`;
|
||||
};
|
||||
|
||||
const lines: string[] = [];
|
||||
lines.push(border(`╭${"─".repeat(contentWidth)}╮`));
|
||||
lines.push(row(this.theme.bold(` Send to: ${this.targetLabel}`)));
|
||||
lines.push(row(this.theme.fg("dim", ` ${this.target.cwd} • ${this.target.model}`)));
|
||||
lines.push(border(`├${"─".repeat(contentWidth)}┤`));
|
||||
lines.push(row());
|
||||
|
||||
if (this.sending) {
|
||||
lines.push(row(this.theme.fg("dim", " Sending...")));
|
||||
} else if (this.error) {
|
||||
lines.push(row(this.theme.fg("error", ` Error: ${this.error}`)));
|
||||
lines.push(row());
|
||||
lines.push(row(` > ${this.inputBuffer}█`));
|
||||
} else {
|
||||
lines.push(row(` > ${this.inputBuffer}█`));
|
||||
}
|
||||
|
||||
lines.push(row());
|
||||
lines.push(border(`├${"─".repeat(contentWidth)}┤`));
|
||||
lines.push(row(this.theme.fg("dim", ` ${footer}`)));
|
||||
lines.push(border(`╰${"─".repeat(contentWidth)}╯`));
|
||||
|
||||
return lines;
|
||||
}
|
||||
}
|
||||
76
extensions/pi-intercom/ui/inline-message.ts
Normal file
76
extensions/pi-intercom/ui/inline-message.ts
Normal file
@@ -0,0 +1,76 @@
|
||||
import type { Component } from "@mariozechner/pi-tui";
|
||||
import { truncateToWidth, visibleWidth, wrapTextWithAnsi } from "@mariozechner/pi-tui";
|
||||
import type { Theme } from "@mariozechner/pi-coding-agent";
|
||||
import type { SessionInfo, Message } from "../types.js";
|
||||
|
||||
export class InlineMessageComponent implements Component {
|
||||
private from: SessionInfo;
|
||||
private message: Message;
|
||||
private theme: Theme;
|
||||
private replyCommand?: string;
|
||||
private bodyText?: string;
|
||||
|
||||
constructor(from: SessionInfo, message: Message, theme: Theme, replyCommand?: string, bodyText?: string) {
|
||||
this.from = from;
|
||||
this.message = message;
|
||||
this.theme = theme;
|
||||
this.replyCommand = replyCommand;
|
||||
this.bodyText = bodyText;
|
||||
}
|
||||
|
||||
invalidate(): void {}
|
||||
|
||||
render(width: number): string[] {
|
||||
const lines: string[] = [];
|
||||
const borderChar = "─";
|
||||
if (width < 3) {
|
||||
return [truncateToWidth(`From ${this.from.name || this.from.id.slice(0, 8)}`, width)];
|
||||
}
|
||||
const bodyWidth = Math.max(1, width - 2);
|
||||
|
||||
const senderName = this.from.name || this.from.id.slice(0, 8);
|
||||
const header = ` 📨 From: ${senderName} (${this.from.cwd}) `;
|
||||
const headerText = truncateToWidth(header, bodyWidth, "");
|
||||
const headerPadding = Math.max(0, bodyWidth - visibleWidth(headerText));
|
||||
lines.push(this.theme.fg("accent", `╭${headerText}${borderChar.repeat(headerPadding)}╮`));
|
||||
|
||||
const contentLines = wrapTextWithAnsi(this.bodyText || this.message.content.text, bodyWidth);
|
||||
for (const line of contentLines) {
|
||||
const text = truncateToWidth(line, bodyWidth, "");
|
||||
const padding = Math.max(0, bodyWidth - visibleWidth(text));
|
||||
lines.push(this.theme.fg("accent", `│${text}${" ".repeat(padding)}│`));
|
||||
}
|
||||
|
||||
if (this.replyCommand) {
|
||||
lines.push(this.theme.fg("accent", `│${" ".repeat(bodyWidth)}│`));
|
||||
const replyLines = wrapTextWithAnsi(this.theme.fg("dim", ` ↩ To reply: ${this.replyCommand}`), bodyWidth);
|
||||
for (const line of replyLines) {
|
||||
const text = truncateToWidth(line, bodyWidth, "");
|
||||
const padding = Math.max(0, bodyWidth - visibleWidth(text));
|
||||
lines.push(this.theme.fg("accent", `│${text}${" ".repeat(padding)}│`));
|
||||
}
|
||||
}
|
||||
|
||||
if (this.message.content.attachments?.length) {
|
||||
lines.push(this.theme.fg("accent", `│${" ".repeat(bodyWidth)}│`));
|
||||
for (const att of this.message.content.attachments) {
|
||||
const label = this.theme.fg("dim", ` 📎 ${att.name}`);
|
||||
const text = truncateToWidth(label, bodyWidth, "");
|
||||
const padding = Math.max(0, bodyWidth - visibleWidth(text));
|
||||
lines.push(this.theme.fg("accent", `│${text}${" ".repeat(padding)}│`));
|
||||
}
|
||||
}
|
||||
|
||||
if (this.message.replyTo && !this.message.expectsReply) {
|
||||
lines.push(this.theme.fg("accent", `│${" ".repeat(bodyWidth)}│`));
|
||||
const reply = this.theme.fg("dim", ` ↳ Reply to ${this.message.replyTo.slice(0, 8)}`);
|
||||
const text = truncateToWidth(reply, bodyWidth, "");
|
||||
const padding = Math.max(0, bodyWidth - visibleWidth(text));
|
||||
lines.push(this.theme.fg("accent", `│${text}${" ".repeat(padding)}│`));
|
||||
}
|
||||
|
||||
lines.push(this.theme.fg("accent", `╰${borderChar.repeat(bodyWidth)}╯`));
|
||||
|
||||
return lines;
|
||||
}
|
||||
}
|
||||
162
extensions/pi-intercom/ui/session-list.ts
Normal file
162
extensions/pi-intercom/ui/session-list.ts
Normal file
@@ -0,0 +1,162 @@
|
||||
import type { Component } from "@mariozechner/pi-tui";
|
||||
import { truncateToWidth, visibleWidth } from "@mariozechner/pi-tui";
|
||||
import type { KeybindingsManager, Theme } from "@mariozechner/pi-coding-agent";
|
||||
import type { SessionInfo } from "../types.js";
|
||||
|
||||
function middleTruncate(text: string, maxWidth: number): string {
|
||||
if (visibleWidth(text) <= maxWidth) {
|
||||
return text;
|
||||
}
|
||||
if (maxWidth <= 3) {
|
||||
return truncateToWidth(text, maxWidth, "");
|
||||
}
|
||||
|
||||
const chars = [...text];
|
||||
const targetSideWidth = Math.max(1, Math.floor((maxWidth - 1) / 2));
|
||||
|
||||
let left = "";
|
||||
for (const char of chars) {
|
||||
if (visibleWidth(left + char) > targetSideWidth) break;
|
||||
left += char;
|
||||
}
|
||||
|
||||
let right = "";
|
||||
for (const char of chars.slice().reverse()) {
|
||||
if (visibleWidth(char + right) > targetSideWidth) break;
|
||||
right = char + right;
|
||||
}
|
||||
|
||||
return truncateToWidth(`${left}…${right}`, maxWidth, "");
|
||||
}
|
||||
|
||||
function shortSessionId(sessionId: string): string {
|
||||
return sessionId.slice(0, 8);
|
||||
}
|
||||
|
||||
function sessionTitle(session: SessionInfo, options?: { self?: boolean; sameCwd?: boolean }): string {
|
||||
const name = session.name || "Unnamed session";
|
||||
const tags = [options?.self ? "self" : undefined, options?.sameCwd ? "same cwd" : undefined]
|
||||
.filter((tag): tag is string => Boolean(tag));
|
||||
const suffix = tags.length ? ` [${tags.join(", ")}]` : "";
|
||||
return `${name} (${shortSessionId(session.id)})${suffix}`;
|
||||
}
|
||||
|
||||
export class SessionListOverlay implements Component {
|
||||
private theme: Theme;
|
||||
private keybindings: KeybindingsManager;
|
||||
private currentSession: SessionInfo;
|
||||
private done: (result: SessionInfo | undefined) => void;
|
||||
private sessions: SessionInfo[];
|
||||
private selectedIndex = 0;
|
||||
private maxVisible = 8;
|
||||
|
||||
constructor(
|
||||
theme: Theme,
|
||||
keybindings: KeybindingsManager,
|
||||
currentSession: SessionInfo,
|
||||
sessions: SessionInfo[],
|
||||
done: (result: SessionInfo | undefined) => void,
|
||||
) {
|
||||
this.theme = theme;
|
||||
this.keybindings = keybindings;
|
||||
this.currentSession = currentSession;
|
||||
this.sessions = sessions;
|
||||
this.done = done;
|
||||
}
|
||||
|
||||
private onSessionSelect(sessionId: string): void {
|
||||
const session = this.sessions.find(s => s.id === sessionId);
|
||||
if (!session) return;
|
||||
this.done(session);
|
||||
}
|
||||
|
||||
invalidate(): void {}
|
||||
|
||||
handleInput(data: string): void {
|
||||
if (this.keybindings.matches(data, "tui.select.cancel")) {
|
||||
this.done(undefined);
|
||||
return;
|
||||
}
|
||||
|
||||
if (this.sessions.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (this.keybindings.matches(data, "tui.select.up")) {
|
||||
this.selectedIndex = this.selectedIndex === 0 ? this.sessions.length - 1 : this.selectedIndex - 1;
|
||||
return;
|
||||
}
|
||||
|
||||
if (this.keybindings.matches(data, "tui.select.down")) {
|
||||
this.selectedIndex = this.selectedIndex === this.sessions.length - 1 ? 0 : this.selectedIndex + 1;
|
||||
return;
|
||||
}
|
||||
|
||||
if (this.keybindings.matches(data, "tui.select.confirm")) {
|
||||
const session = this.sessions[this.selectedIndex];
|
||||
if (session) {
|
||||
this.onSessionSelect(session.id);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
render(width: number): string[] {
|
||||
const innerWidth = Math.max(36, Math.min(width - 2, 88));
|
||||
const contentWidth = Math.max(1, innerWidth - 2);
|
||||
const footer = `${this.keybindings.getKeys("tui.select.confirm").join("/")}: Message • ${this.keybindings.getKeys("tui.select.cancel").join("/")}: Close`;
|
||||
const border = (text: string) => this.theme.fg("accent", text);
|
||||
const row = (text = "") => {
|
||||
const clipped = truncateToWidth(text, contentWidth, "", true);
|
||||
return `${border("│")}${clipped}${" ".repeat(Math.max(0, contentWidth - visibleWidth(clipped)))}${border("│")}`;
|
||||
};
|
||||
|
||||
const lines: string[] = [];
|
||||
lines.push(border(`╭${"─".repeat(contentWidth)}╮`));
|
||||
lines.push(row(this.theme.bold(" Current Session")));
|
||||
lines.push(border(`├${"─".repeat(contentWidth)}┤`));
|
||||
lines.push(row());
|
||||
lines.push(row(` ${this.theme.fg("dim", sessionTitle(this.currentSession, { self: true }))}`));
|
||||
lines.push(row(` ${this.theme.fg("dim", `${middleTruncate(this.currentSession.cwd, Math.max(8, contentWidth - 4))} • ${this.currentSession.model}`)}`));
|
||||
lines.push(row());
|
||||
lines.push(border(`├${"─".repeat(contentWidth)}┤`));
|
||||
lines.push(row(this.theme.bold(" Other Sessions")));
|
||||
lines.push(row());
|
||||
|
||||
if (this.sessions.length === 0) {
|
||||
lines.push(row(this.theme.fg("dim", " No other intercom-connected sessions")));
|
||||
} else {
|
||||
const startIndex = Math.max(
|
||||
0,
|
||||
Math.min(this.selectedIndex - Math.floor(this.maxVisible / 2), this.sessions.length - this.maxVisible),
|
||||
);
|
||||
const endIndex = Math.min(startIndex + this.maxVisible, this.sessions.length);
|
||||
|
||||
for (let index = startIndex; index < endIndex; index += 1) {
|
||||
const session = this.sessions[index];
|
||||
const isSelected = index === this.selectedIndex;
|
||||
const sameCwd = session.cwd === this.currentSession.cwd;
|
||||
const prefix = isSelected ? this.theme.fg("accent", "→ ") : " ";
|
||||
const title = sessionTitle(session, { sameCwd });
|
||||
const pathText = `${middleTruncate(session.cwd, Math.max(8, contentWidth - 4))} • ${session.model}`;
|
||||
|
||||
lines.push(row(`${prefix}${isSelected ? this.theme.fg("accent", title) : title}`));
|
||||
lines.push(row(` ${this.theme.fg("dim", pathText)}`));
|
||||
if (index < endIndex - 1) {
|
||||
lines.push(row());
|
||||
}
|
||||
}
|
||||
|
||||
if (startIndex > 0 || endIndex < this.sessions.length) {
|
||||
lines.push(row());
|
||||
lines.push(row(this.theme.fg("dim", ` ${this.selectedIndex + 1}/${this.sessions.length}`)));
|
||||
}
|
||||
}
|
||||
|
||||
lines.push(row());
|
||||
lines.push(border(`├${"─".repeat(contentWidth)}┤`));
|
||||
lines.push(row(this.theme.fg("dim", ` ${footer}`)));
|
||||
lines.push(border(`╰${"─".repeat(contentWidth)}╯`));
|
||||
|
||||
return lines;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user