File size: 4,960 Bytes
f500658 | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 | import { type Component, Container, getKeybindings, Spacer, Text, truncateToWidth } from "@earendil-works/pi-tui";
import { theme } from "../theme/theme.ts";
import { DynamicBorder } from "./dynamic-border.ts";
interface UserMessageItem {
id: string; // Entry ID in the session
text: string; // The message text
timestamp?: string; // Optional timestamp if available
}
/**
* Custom user message list component with selection
*/
class UserMessageList implements Component {
private messages: UserMessageItem[] = [];
private selectedIndex: number = 0;
public onSelect?: (entryId: string) => void;
public onCancel?: () => void;
private maxVisible: number = 10; // Max messages visible
constructor(messages: UserMessageItem[], initialSelectedId?: string) {
// Store messages in chronological order (oldest to newest)
this.messages = messages;
const initialIndex = initialSelectedId ? messages.findIndex((message) => message.id === initialSelectedId) : -1;
// Start with selected message if provided, else default to the most recent
this.selectedIndex = initialIndex >= 0 ? initialIndex : Math.max(0, messages.length - 1);
}
invalidate(): void {
// No cached state to invalidate currently
}
render(width: number): string[] {
const lines: string[] = [];
if (this.messages.length === 0) {
lines.push(theme.fg("muted", " No user messages found"));
return lines;
}
// Calculate visible range with scrolling
const startIndex = Math.max(
0,
Math.min(this.selectedIndex - Math.floor(this.maxVisible / 2), this.messages.length - this.maxVisible),
);
const endIndex = Math.min(startIndex + this.maxVisible, this.messages.length);
// Render visible messages (2 lines per message + blank line)
for (let i = startIndex; i < endIndex; i++) {
const message = this.messages[i];
const isSelected = i === this.selectedIndex;
// Normalize message to single line
const normalizedMessage = message.text.replace(/\n/g, " ").trim();
// First line: cursor + message
const cursor = isSelected ? theme.fg("accent", "› ") : " ";
const maxMsgWidth = width - 2; // Account for cursor (2 chars)
const truncatedMsg = truncateToWidth(normalizedMessage, maxMsgWidth);
const messageLine = cursor + (isSelected ? theme.bold(truncatedMsg) : truncatedMsg);
lines.push(messageLine);
// Second line: metadata (position in history)
const position = i + 1;
const metadata = ` Message ${position} of ${this.messages.length}`;
const metadataLine = theme.fg("muted", metadata);
lines.push(metadataLine);
lines.push(""); // Blank line between messages
}
// Add scroll indicator if needed
if (startIndex > 0 || endIndex < this.messages.length) {
const scrollInfo = theme.fg("muted", ` (${this.selectedIndex + 1}/${this.messages.length})`);
lines.push(scrollInfo);
}
return lines;
}
handleInput(keyData: string): void {
const kb = getKeybindings();
// Up arrow - go to previous (older) message, wrap to bottom when at top
if (kb.matches(keyData, "tui.select.up")) {
this.selectedIndex = this.selectedIndex === 0 ? this.messages.length - 1 : this.selectedIndex - 1;
}
// Down arrow - go to next (newer) message, wrap to top when at bottom
else if (kb.matches(keyData, "tui.select.down")) {
this.selectedIndex = this.selectedIndex === this.messages.length - 1 ? 0 : this.selectedIndex + 1;
}
// Enter - select message and branch
else if (kb.matches(keyData, "tui.select.confirm")) {
const selected = this.messages[this.selectedIndex];
if (selected && this.onSelect) {
this.onSelect(selected.id);
}
}
// Escape - cancel
else if (kb.matches(keyData, "tui.select.cancel")) {
if (this.onCancel) {
this.onCancel();
}
}
}
}
/**
* Component that renders a user message selector for branching
*/
export class UserMessageSelectorComponent extends Container {
private messageList: UserMessageList;
constructor(
messages: UserMessageItem[],
onSelect: (entryId: string) => void,
onCancel: () => void,
initialSelectedId?: string,
) {
super();
// Add header
this.addChild(new Spacer(1));
this.addChild(new Text(theme.bold("Fork from Message"), 1, 0));
this.addChild(
new Text(
theme.fg("muted", "Select a user message to copy the active path up to that point into a new session"),
1,
0,
),
);
this.addChild(new Spacer(1));
this.addChild(new DynamicBorder());
this.addChild(new Spacer(1));
// Create message list
this.messageList = new UserMessageList(messages, initialSelectedId);
this.messageList.onSelect = onSelect;
this.messageList.onCancel = onCancel;
this.addChild(this.messageList);
// Add bottom border
this.addChild(new Spacer(1));
this.addChild(new DynamicBorder());
// Auto-cancel if no messages
if (messages.length === 0) {
setTimeout(() => onCancel(), 100);
}
}
getMessageList(): UserMessageList {
return this.messageList;
}
}
|