ei-tui 0.6.6 → 0.7.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.
- package/package.json +1 -1
- package/src/cli/README.md +16 -7
- package/src/cli/commands/people.ts +1 -0
- package/src/cli/mcp.ts +36 -11
- package/src/cli/persona-filter.ts +42 -0
- package/src/cli/retrieval.ts +3 -1
- package/src/cli.ts +18 -6
- package/src/core/handlers/human-extraction.ts +1 -0
- package/src/core/handlers/human-matching.ts +20 -4
- package/src/core/handlers/index.ts +2 -1
- package/src/core/handlers/persona-response.ts +5 -0
- package/src/core/handlers/utils.ts +4 -1
- package/src/core/orchestrators/ceremony.ts +2 -2
- package/src/core/orchestrators/human-extraction.ts +24 -7
- package/src/core/persona-manager.ts +3 -0
- package/src/core/processor.ts +22 -2
- package/src/core/prompt-context-builder.ts +40 -10
- package/src/core/queue-manager.ts +18 -0
- package/src/core/room-manager.ts +21 -4
- package/src/core/state-manager.ts +74 -0
- package/src/core/tools/builtin/read-memory.ts +1 -1
- package/src/core/types/data-items.ts +1 -0
- package/src/core/types/entities.ts +13 -0
- package/src/core/types/enums.ts +1 -0
- package/src/core/types/integrations.ts +4 -0
- package/src/core/types/rooms.ts +2 -0
- package/src/core/utils/identifier-utils.ts +24 -0
- package/src/core/utils/theme-codec.ts +78 -0
- package/src/integrations/claude-code/importer.ts +3 -57
- package/src/integrations/cursor/importer.ts +2 -52
- package/src/integrations/opencode/importer.ts +1 -0
- package/src/prompts/response/sections.ts +1 -1
- package/src/prompts/response/types.ts +1 -0
- package/src/prompts/room/index.ts +2 -2
- package/src/prompts/room/sections.ts +4 -4
- package/src/prompts/room/types.ts +4 -0
- package/tui/src/commands/activate.tsx +7 -6
- package/tui/src/commands/context.tsx +188 -2
- package/tui/src/components/CYPTreeOverlay.tsx +357 -0
- package/tui/src/components/MAPScoreOverlay.tsx +300 -0
- package/tui/src/components/MessageList.tsx +14 -3
- package/tui/src/components/RoomMessageList.tsx +15 -3
- package/tui/src/context/ei.tsx +20 -0
- package/tui/src/util/cyp-tree.ts +62 -0
- package/tui/src/util/yaml-context.ts +87 -1
|
@@ -277,7 +277,7 @@ export function buildQuotesSection(quotes: Quote[], human: ResponsePromptData["h
|
|
|
277
277
|
const idToName = new Map(allDataItems.map(item => [item.id, item.name]));
|
|
278
278
|
|
|
279
279
|
const formatted = quotes.map(q => {
|
|
280
|
-
const speaker = q.speaker === "human" ?
|
|
280
|
+
const speaker = q.speaker === "human" ? human.name : q.speaker;
|
|
281
281
|
const date = formatDate(q.timestamp);
|
|
282
282
|
const linkedNames = q.data_item_ids
|
|
283
283
|
.map(id => idToName.get(id))
|
|
@@ -97,10 +97,10 @@ There is no objectively correct answer. Pick the response you find most interest
|
|
|
97
97
|
**The MAP dynamic**: Every participant — personas and the Human alike — can see your description and traits. They have been crafting their responses specifically to appeal to your tastes. Personas are also constrained to stay true to their own identities; the Human is not. Factor that in if you choose to.`;
|
|
98
98
|
|
|
99
99
|
const contextSection = context.length > 0
|
|
100
|
-
? buildRoomHistorySection(context)
|
|
100
|
+
? buildRoomHistorySection(context, data.human.name)
|
|
101
101
|
: "";
|
|
102
102
|
|
|
103
|
-
const candidatesSection = buildJudgeCandidatesSection(candidates);
|
|
103
|
+
const candidatesSection = buildJudgeCandidatesSection(candidates, data.human.name);
|
|
104
104
|
const decisionSection = buildJudgeDecisionFormatSection();
|
|
105
105
|
const currentTime = formatCurrentTime();
|
|
106
106
|
|
|
@@ -32,11 +32,11 @@ export function buildRoomParticipantsSection(participants: RoomParticipantIdenti
|
|
|
32
32
|
return `## Others in the Room\n\n${lines.join("\n\n")}`;
|
|
33
33
|
}
|
|
34
34
|
|
|
35
|
-
export function buildRoomHistorySection(history: RoomHistoryMessage[]): string {
|
|
35
|
+
export function buildRoomHistorySection(history: RoomHistoryMessage[], humanName: string): string {
|
|
36
36
|
if (history.length === 0) return "";
|
|
37
37
|
|
|
38
38
|
const lines = history.map(msg => {
|
|
39
|
-
const speaker = msg.speaker_id === "human" ?
|
|
39
|
+
const speaker = msg.speaker_id === "human" ? humanName : msg.speaker_name;
|
|
40
40
|
if (msg.silence_reason) {
|
|
41
41
|
return `**${speaker}**: *[chose not to respond: ${msg.silence_reason}]*`;
|
|
42
42
|
}
|
|
@@ -116,9 +116,9 @@ ${lines.join("\n\n")}
|
|
|
116
116
|
Respond as yourself — your read on this moment, your relationship with the human, the reaction that comes naturally to who you are. A room with distinct voices is more alive than one with echoes.`;
|
|
117
117
|
}
|
|
118
118
|
|
|
119
|
-
export function buildJudgeCandidatesSection(candidates: RoomJudgeCandidate[]): string {
|
|
119
|
+
export function buildJudgeCandidatesSection(candidates: RoomJudgeCandidate[], humanName: string): string {
|
|
120
120
|
const lines = candidates.map((c, i) => {
|
|
121
|
-
const speaker = c.speaker_id === "human" ?
|
|
121
|
+
const speaker = c.speaker_id === "human" ? humanName : c.speaker_name;
|
|
122
122
|
const content = c.silence_reason
|
|
123
123
|
? `*[chose not to respond: ${c.silence_reason}]*`
|
|
124
124
|
: [c.verbal_response, c.action_response ? `*${c.action_response}*` : ""].filter(Boolean).join(" ");
|
|
@@ -39,6 +39,7 @@ export interface RoomResponsePromptData {
|
|
|
39
39
|
};
|
|
40
40
|
other_participants: RoomParticipantIdentity[];
|
|
41
41
|
human: {
|
|
42
|
+
name: string;
|
|
42
43
|
facts: Fact[];
|
|
43
44
|
topics: Topic[];
|
|
44
45
|
people: Person[];
|
|
@@ -72,6 +73,9 @@ export interface RoomJudgePromptData {
|
|
|
72
73
|
long_description?: string;
|
|
73
74
|
traits: PersonaTrait[];
|
|
74
75
|
};
|
|
76
|
+
human: {
|
|
77
|
+
name: string;
|
|
78
|
+
};
|
|
75
79
|
context: RoomHistoryMessage[];
|
|
76
80
|
candidates: RoomJudgeCandidate[];
|
|
77
81
|
}
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import type { Command } from "./registry";
|
|
2
2
|
import { RoomMode } from "../../../src/core/types/enums.js";
|
|
3
3
|
import { openCYPEditor } from "../util/cyp-editor.js";
|
|
4
|
+
import { buildCYPTree } from "../util/cyp-tree.js";
|
|
4
5
|
|
|
5
6
|
export const activateCommand: Command = {
|
|
6
7
|
name: "activate",
|
|
@@ -55,18 +56,18 @@ export const activateCommand: Command = {
|
|
|
55
56
|
|
|
56
57
|
const num = parseInt(args[0], 10);
|
|
57
58
|
if (isNaN(num) || num < 1) {
|
|
58
|
-
ctx.showNotification("Usage: /activate <num> (1-based
|
|
59
|
+
ctx.showNotification("Usage: /activate <num> (1-based BFS tree number)", "error");
|
|
59
60
|
return;
|
|
60
61
|
}
|
|
61
62
|
|
|
62
63
|
const messages = ctx.ei.roomMessages();
|
|
63
|
-
const
|
|
64
|
-
|
|
65
|
-
|
|
64
|
+
const { numToId } = buildCYPTree(messages);
|
|
65
|
+
const targetId = numToId.get(num);
|
|
66
|
+
if (!targetId) {
|
|
67
|
+
ctx.showNotification(`No message at tree position ${num} (tree has ${numToId.size} nodes)`, "error");
|
|
66
68
|
return;
|
|
67
69
|
}
|
|
68
|
-
|
|
69
|
-
await ctx.ei.selectCYPBranch(target.id);
|
|
70
|
+
await ctx.ei.selectCYPBranch(targetId);
|
|
70
71
|
|
|
71
72
|
const freshRoom = ctx.ei.getRoom(roomId);
|
|
72
73
|
const newActiveNodeId = freshRoom?.active_node_id;
|
|
@@ -1,8 +1,11 @@
|
|
|
1
1
|
import type { Command } from "./registry.js";
|
|
2
2
|
import { spawnEditor } from "../util/editor.js";
|
|
3
|
-
import { contextToYAML, contextFromYAML } from "../util/yaml-serializers.js";
|
|
3
|
+
import { contextToYAML, contextFromYAML, ffaContextToYAML, ffaContextFromYAML } from "../util/yaml-serializers.js";
|
|
4
4
|
import { logger } from "../util/logger.js";
|
|
5
5
|
import { ConfirmOverlay } from "../components/ConfirmOverlay.js";
|
|
6
|
+
import { CYPTreeOverlay } from "../components/CYPTreeOverlay.js";
|
|
7
|
+
import { MAPScoreOverlay } from "../components/MAPScoreOverlay.js";
|
|
8
|
+
import { RoomMode } from "../../../src/core/types/enums.js";
|
|
6
9
|
|
|
7
10
|
export const contextCommand: Command = {
|
|
8
11
|
name: "context",
|
|
@@ -12,8 +15,191 @@ export const contextCommand: Command = {
|
|
|
12
15
|
|
|
13
16
|
async execute(_args, ctx) {
|
|
14
17
|
const personaId = ctx.ei.activePersonaId();
|
|
18
|
+
const roomId = ctx.ei.activeRoomId();
|
|
19
|
+
|
|
20
|
+
if (roomId) {
|
|
21
|
+
const room = ctx.ei.getRoom(roomId);
|
|
22
|
+
if (!room) {
|
|
23
|
+
ctx.showNotification("Room not found", "warn");
|
|
24
|
+
return;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
if (room.mode === RoomMode.ChooseYourPath) {
|
|
28
|
+
const activeNodeId = room.active_node_id;
|
|
29
|
+
if (!activeNodeId) {
|
|
30
|
+
ctx.showNotification("No active node in room", "warn");
|
|
31
|
+
return;
|
|
32
|
+
}
|
|
33
|
+
ctx.showOverlay((hideOverlay) => (
|
|
34
|
+
<CYPTreeOverlay
|
|
35
|
+
roomId={roomId}
|
|
36
|
+
roomName={room.display_name}
|
|
37
|
+
messages={ctx.ei.roomMessages()}
|
|
38
|
+
activeNodeId={activeNodeId}
|
|
39
|
+
activeRoomPath={ctx.ei.roomActivePath()}
|
|
40
|
+
personas={ctx.ei.personas()}
|
|
41
|
+
onSelectBranch={(msgId) => ctx.ei.selectCYPBranch(msgId)}
|
|
42
|
+
onDismiss={hideOverlay}
|
|
43
|
+
/>
|
|
44
|
+
), ctx.renderer);
|
|
45
|
+
return;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
if (room.mode === RoomMode.FreeForAll) {
|
|
49
|
+
const allMessages = ctx.ei.roomMessages();
|
|
50
|
+
if (allMessages.length === 0) {
|
|
51
|
+
ctx.showNotification("No messages to edit", "info");
|
|
52
|
+
return;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
const personas = ctx.ei.personas();
|
|
56
|
+
const speakerMap = new Map(personas.map((p) => [p.id, p.display_name]));
|
|
57
|
+
|
|
58
|
+
const originalStatus = new Map(allMessages.map((m) => [m.id, m.context_status]));
|
|
59
|
+
|
|
60
|
+
let yamlContent = ffaContextToYAML(allMessages, speakerMap);
|
|
61
|
+
let editorIteration = 0;
|
|
62
|
+
|
|
63
|
+
while (true) {
|
|
64
|
+
editorIteration++;
|
|
65
|
+
logger.debug("[context] ffa starting editor iteration", { iteration: editorIteration });
|
|
66
|
+
|
|
67
|
+
const result = await spawnEditor({
|
|
68
|
+
initialContent: yamlContent,
|
|
69
|
+
filename: "ffa-context.yaml",
|
|
70
|
+
renderer: ctx.renderer,
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
logger.debug("[context] ffa editor returned", {
|
|
74
|
+
iteration: editorIteration,
|
|
75
|
+
aborted: result.aborted,
|
|
76
|
+
success: result.success,
|
|
77
|
+
hasContent: result.content !== null,
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
if (result.aborted) {
|
|
81
|
+
ctx.showNotification("Editor cancelled", "info");
|
|
82
|
+
return;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
if (!result.success) {
|
|
86
|
+
ctx.showNotification("Editor failed to open", "error");
|
|
87
|
+
return;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
if (result.content === null) {
|
|
91
|
+
ctx.showNotification("No changes made", "info");
|
|
92
|
+
return;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
try {
|
|
96
|
+
const parsed = ffaContextFromYAML(result.content);
|
|
97
|
+
|
|
98
|
+
if (parsed.deletedMessageIds.length > 0) {
|
|
99
|
+
const count = parsed.deletedMessageIds.length;
|
|
100
|
+
const hasImplicit = parsed.implicitDeleteCount > 0;
|
|
101
|
+
const confirmed = await new Promise<boolean>((resolve) => {
|
|
102
|
+
ctx.showOverlay((hideOverlay) => (
|
|
103
|
+
<ConfirmOverlay
|
|
104
|
+
message={`Delete ${count} message${count === 1 ? "" : "s"}?${hasImplicit ? `\n(includes ${parsed.implicitDeleteCount} persona response${parsed.implicitDeleteCount === 1 ? "" : "s"})` : ""}`}
|
|
105
|
+
onConfirm={() => { hideOverlay(); resolve(true); }}
|
|
106
|
+
onCancel={() => { hideOverlay(); resolve(false); }}
|
|
107
|
+
/>
|
|
108
|
+
), ctx.renderer);
|
|
109
|
+
});
|
|
110
|
+
if (!confirmed) {
|
|
111
|
+
ctx.showNotification("Delete cancelled", "info");
|
|
112
|
+
return;
|
|
113
|
+
}
|
|
114
|
+
await ctx.ei.deleteRoomMessages(roomId, parsed.deletedMessageIds);
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
for (const msg of parsed.messages) {
|
|
118
|
+
const orig = originalStatus.get(msg.id);
|
|
119
|
+
if (orig !== undefined && orig !== msg.context_status) {
|
|
120
|
+
await ctx.ei.setRoomMessageContextStatus(roomId, msg.id, msg.context_status);
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
const deleteCount = parsed.deletedMessageIds.length;
|
|
125
|
+
const notification =
|
|
126
|
+
deleteCount > 0
|
|
127
|
+
? `Context updated (${deleteCount} message${deleteCount === 1 ? "" : "s"} deleted)`
|
|
128
|
+
: "Context updated";
|
|
129
|
+
|
|
130
|
+
ctx.showNotification(notification, "info");
|
|
131
|
+
return;
|
|
132
|
+
} catch (parseError) {
|
|
133
|
+
const errorMsg = parseError instanceof Error ? parseError.message : String(parseError);
|
|
134
|
+
logger.debug("[context] ffa YAML parse error, prompting for re-edit", {
|
|
135
|
+
iteration: editorIteration,
|
|
136
|
+
error: errorMsg,
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
const shouldReEdit = await new Promise<boolean>((resolve) => {
|
|
140
|
+
ctx.showOverlay((hideOverlay, hideForEditor) => (
|
|
141
|
+
<ConfirmOverlay
|
|
142
|
+
message={`YAML parse error:\n${errorMsg}\n\nRe-edit?`}
|
|
143
|
+
onConfirm={() => {
|
|
144
|
+
logger.debug("[context] ffa user confirmed re-edit");
|
|
145
|
+
hideForEditor();
|
|
146
|
+
resolve(true);
|
|
147
|
+
}}
|
|
148
|
+
onCancel={() => {
|
|
149
|
+
logger.debug("[context] ffa user cancelled re-edit");
|
|
150
|
+
hideOverlay();
|
|
151
|
+
resolve(false);
|
|
152
|
+
}}
|
|
153
|
+
/>
|
|
154
|
+
), ctx.renderer);
|
|
155
|
+
});
|
|
156
|
+
|
|
157
|
+
logger.debug("[context] ffa shouldReEdit", { shouldReEdit, iteration: editorIteration });
|
|
158
|
+
|
|
159
|
+
if (shouldReEdit) {
|
|
160
|
+
yamlContent = result.content;
|
|
161
|
+
logger.debug("[context] ffa continuing to next iteration");
|
|
162
|
+
continue;
|
|
163
|
+
} else {
|
|
164
|
+
ctx.showNotification("Changes discarded", "info");
|
|
165
|
+
return;
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
if (room.mode === RoomMode.MessagesAgainstPersona) {
|
|
172
|
+
if (!room.judge_persona_id) {
|
|
173
|
+
ctx.showNotification("No judge configured for this room", "warn");
|
|
174
|
+
return;
|
|
175
|
+
}
|
|
176
|
+
const human = await ctx.ei.getHuman();
|
|
177
|
+
const humanName =
|
|
178
|
+
human.settings?.name_display ||
|
|
179
|
+
human.facts?.find((f) => f.name === "Nickname/Preferred Name")?.description ||
|
|
180
|
+
"You";
|
|
181
|
+
ctx.showOverlay((hideOverlay) => (
|
|
182
|
+
<MAPScoreOverlay
|
|
183
|
+
roomId={roomId}
|
|
184
|
+
roomName={room.display_name}
|
|
185
|
+
messages={ctx.ei.roomMessages()}
|
|
186
|
+
activeNodeId={room.active_node_id ?? ""}
|
|
187
|
+
activeRoomPath={ctx.ei.roomActivePath()}
|
|
188
|
+
personas={ctx.ei.personas()}
|
|
189
|
+
judgePersonaId={room.judge_persona_id!}
|
|
190
|
+
humanName={humanName}
|
|
191
|
+
onDismiss={hideOverlay}
|
|
192
|
+
/>
|
|
193
|
+
), ctx.renderer);
|
|
194
|
+
return;
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
ctx.showNotification("Unknown room mode", "warn");
|
|
198
|
+
return;
|
|
199
|
+
}
|
|
200
|
+
|
|
15
201
|
if (!personaId) {
|
|
16
|
-
ctx.showNotification("No active
|
|
202
|
+
ctx.showNotification("No active chat", "warn");
|
|
17
203
|
return;
|
|
18
204
|
}
|
|
19
205
|
|
|
@@ -0,0 +1,357 @@
|
|
|
1
|
+
import { useKeyboard } from "@opentui/solid";
|
|
2
|
+
import { For, createMemo, createSignal, onMount, onCleanup, createEffect } from "solid-js";
|
|
3
|
+
import type { RoomMessage, PersonaSummary } from "../../../src/core/types.js";
|
|
4
|
+
import type { ScrollBoxRenderable } from "@opentui/core";
|
|
5
|
+
import { useKeyboardNav } from "../context/keyboard.js";
|
|
6
|
+
import { buildCYPTree, getSubtreeIds } from "../util/cyp-tree.js";
|
|
7
|
+
|
|
8
|
+
export interface CYPTreeOverlayProps {
|
|
9
|
+
roomId: string;
|
|
10
|
+
roomName?: string;
|
|
11
|
+
messages: RoomMessage[];
|
|
12
|
+
activeNodeId: string;
|
|
13
|
+
activeRoomPath: RoomMessage[];
|
|
14
|
+
personas: PersonaSummary[];
|
|
15
|
+
onSelectBranch: (messageId: string) => Promise<void>;
|
|
16
|
+
onDismiss: () => void;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
type LineKind = 'normal' | 'masked' | 'your-turn';
|
|
20
|
+
|
|
21
|
+
interface TreeLine {
|
|
22
|
+
messageId: string;
|
|
23
|
+
globalNum: number;
|
|
24
|
+
prefix: string;
|
|
25
|
+
speaker: string;
|
|
26
|
+
preview: string;
|
|
27
|
+
stateIndicator: string;
|
|
28
|
+
kind: LineKind;
|
|
29
|
+
isMaskedOrPlaceholder: boolean;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function getMessageContent(m: RoomMessage): string {
|
|
33
|
+
if (m.content) return m.content;
|
|
34
|
+
if (m.verbal_response) return m.verbal_response;
|
|
35
|
+
if (m.action_response) return m.action_response;
|
|
36
|
+
if (m.silence_reason) return `(${m.silence_reason})`;
|
|
37
|
+
return "";
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export function CYPTreeOverlay(props: CYPTreeOverlayProps) {
|
|
41
|
+
const { setOverlayActive } = useKeyboardNav();
|
|
42
|
+
onMount(() => setOverlayActive(true));
|
|
43
|
+
onCleanup(() => setOverlayActive(false));
|
|
44
|
+
|
|
45
|
+
const treeData = createMemo(() => buildCYPTree(props.messages));
|
|
46
|
+
|
|
47
|
+
const [highlightedId, setHighlightedId] = createSignal<string>(props.activeNodeId);
|
|
48
|
+
const [viewStack, setViewStack] = createSignal<string[]>([]);
|
|
49
|
+
const [numBuffer, setNumBuffer] = createSignal<string>("");
|
|
50
|
+
const [navError, setNavError] = createSignal<string>("");
|
|
51
|
+
|
|
52
|
+
let scrollRef: ScrollBoxRenderable | null = null;
|
|
53
|
+
|
|
54
|
+
createEffect(() => {
|
|
55
|
+
const hid = highlightedId();
|
|
56
|
+
const lines = visibleLines();
|
|
57
|
+
const idx = lines.findIndex(l => l.messageId === hid);
|
|
58
|
+
if (idx < 0 || !scrollRef) return;
|
|
59
|
+
const top = scrollRef.scrollTop;
|
|
60
|
+
const visible = scrollRef.height;
|
|
61
|
+
if (idx < top) {
|
|
62
|
+
scrollRef.scrollTo(idx);
|
|
63
|
+
} else if (idx >= top + visible) {
|
|
64
|
+
scrollRef.scrollTo(idx - visible + 1);
|
|
65
|
+
}
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
const activeRoomPathIds = createMemo(() => new Set(props.activeRoomPath.map((m) => m.id)));
|
|
69
|
+
|
|
70
|
+
const incompleteFamilies = createMemo<Set<string>>(() => {
|
|
71
|
+
const result = new Set<string>();
|
|
72
|
+
const { childrenMap } = treeData();
|
|
73
|
+
childrenMap.forEach((children, parentId) => {
|
|
74
|
+
const hasPersonaChild = children.some(c => c.role === "persona");
|
|
75
|
+
const hasHumanChild = children.some(c => c.role === "human");
|
|
76
|
+
if (hasPersonaChild && !hasHumanChild) result.add(parentId);
|
|
77
|
+
});
|
|
78
|
+
return result;
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
const ICON_COL = 74;
|
|
82
|
+
const NUM_WIDTH = 7;
|
|
83
|
+
|
|
84
|
+
const visibleLines = createMemo<TreeLine[]>(() => {
|
|
85
|
+
const { ordered, idToNum, childrenMap } = treeData();
|
|
86
|
+
if (ordered.length === 0) return [];
|
|
87
|
+
|
|
88
|
+
const stack = viewStack();
|
|
89
|
+
const viewRootId = stack.length > 0 ? stack[stack.length - 1] : null;
|
|
90
|
+
const subtreeIds = viewRootId ? getSubtreeIds(viewRootId, childrenMap) : null;
|
|
91
|
+
const incomplete = incompleteFamilies();
|
|
92
|
+
|
|
93
|
+
const lines: TreeLine[] = [];
|
|
94
|
+
|
|
95
|
+
function buildPrefix(ancestorIsLast: boolean[], isLast: boolean, isDisplayRoot: boolean): string {
|
|
96
|
+
if (isDisplayRoot) return "";
|
|
97
|
+
let p = "";
|
|
98
|
+
for (const anc of ancestorIsLast) p += anc ? " " : "\u2502 ";
|
|
99
|
+
return p + (isLast ? "\u2514\u2500 " : "\u251C\u2500 ");
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
function formatLine(numStr: string, prefix: string, speaker: string, content: string, icon: string): string {
|
|
103
|
+
const prefixLen = prefix.length;
|
|
104
|
+
const available = ICON_COL - NUM_WIDTH - prefixLen - speaker.length - 4 - 1;
|
|
105
|
+
const trimmed = content.length > available ? content.slice(0, Math.max(available - 1, 8)) + "\u2026" : content;
|
|
106
|
+
const body = `${numStr} ${prefix}${speaker}: "${trimmed}"`;
|
|
107
|
+
return body.padEnd(ICON_COL) + " " + icon;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
function pushLine(msgId: string, prefix: string, speaker: string, content: string, icon: string, kind: LineKind, globalNum: number) {
|
|
111
|
+
const numStr = ` ${String(globalNum).padStart(3)} `;
|
|
112
|
+
const formatted = formatLine(numStr, prefix, speaker, content, icon);
|
|
113
|
+
lines.push({
|
|
114
|
+
messageId: msgId,
|
|
115
|
+
globalNum,
|
|
116
|
+
prefix,
|
|
117
|
+
speaker,
|
|
118
|
+
preview: formatted,
|
|
119
|
+
stateIndicator: icon,
|
|
120
|
+
kind,
|
|
121
|
+
isMaskedOrPlaceholder: kind === 'masked' || kind === 'your-turn',
|
|
122
|
+
});
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
function visit(msgId: string, isLast: boolean, ancestorIsLast: boolean[], isDisplayRoot: boolean) {
|
|
126
|
+
const msg = props.messages.find((m) => m.id === msgId);
|
|
127
|
+
if (!msg) return;
|
|
128
|
+
if (subtreeIds && !subtreeIds.has(msgId)) return;
|
|
129
|
+
|
|
130
|
+
const children = childrenMap.get(msgId) ?? [];
|
|
131
|
+
const prefix = buildPrefix(ancestorIsLast, isLast, isDisplayRoot);
|
|
132
|
+
const globalNum = idToNum.get(msgId) ?? 0;
|
|
133
|
+
|
|
134
|
+
let kind: LineKind = 'normal';
|
|
135
|
+
if (msg.parent_id && incomplete.has(msg.parent_id)) kind = 'masked';
|
|
136
|
+
|
|
137
|
+
let icon: string;
|
|
138
|
+
if (msgId === props.activeNodeId) icon = "\u25CF";
|
|
139
|
+
else if (activeRoomPathIds().has(msgId)) icon = "\u25CB";
|
|
140
|
+
else if (kind === 'masked') icon = "\uD83D\uDD12";
|
|
141
|
+
else if (children.length === 0) icon = "\u00B7";
|
|
142
|
+
else icon = "\u25CB";
|
|
143
|
+
|
|
144
|
+
let speaker = "You";
|
|
145
|
+
if (msg.role === "persona" && msg.persona_id) {
|
|
146
|
+
speaker = props.personas.find((p) => p.id === msg.persona_id)?.display_name ?? msg.persona_id;
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
const content = kind === 'masked' ? "[hidden]" : getMessageContent(msg).replace(/\n/g, " ");
|
|
150
|
+
pushLine(msgId, prefix, speaker, content, icon, kind, globalNum);
|
|
151
|
+
|
|
152
|
+
const nextAncestors = isDisplayRoot ? [] : [...ancestorIsLast, isLast];
|
|
153
|
+
const hasHumanChild = children.some(c => c.role === "human");
|
|
154
|
+
const hasPersonaChild = children.some(c => c.role === "persona");
|
|
155
|
+
const needsYourTurn = hasPersonaChild && !hasHumanChild;
|
|
156
|
+
|
|
157
|
+
for (let i = 0; i < children.length; i++) {
|
|
158
|
+
const childIsLast = !needsYourTurn && i === children.length - 1;
|
|
159
|
+
visit(children[i].id, childIsLast, nextAncestors, false);
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
if (needsYourTurn) {
|
|
163
|
+
const ytPrefix = buildPrefix(nextAncestors, true, false);
|
|
164
|
+
const numStr = ` -- `;
|
|
165
|
+
const formatted = formatLine(numStr, ytPrefix, "You", "[Your turn]", "\u270F\uFE0F");
|
|
166
|
+
lines.push({
|
|
167
|
+
messageId: `your-turn-${msgId}`,
|
|
168
|
+
globalNum: 0,
|
|
169
|
+
prefix: ytPrefix,
|
|
170
|
+
speaker: "You",
|
|
171
|
+
preview: formatted,
|
|
172
|
+
stateIndicator: "\u270F\uFE0F",
|
|
173
|
+
kind: 'your-turn',
|
|
174
|
+
isMaskedOrPlaceholder: true,
|
|
175
|
+
});
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
const displayRootId = viewRootId ?? (ordered[0]?.id ?? null);
|
|
180
|
+
if (displayRootId) visit(displayRootId, true, [], true);
|
|
181
|
+
|
|
182
|
+
return lines;
|
|
183
|
+
});
|
|
184
|
+
|
|
185
|
+
useKeyboard((event) => {
|
|
186
|
+
event.preventDefault();
|
|
187
|
+
const key = event.name;
|
|
188
|
+
|
|
189
|
+
if (navError()) setNavError("");
|
|
190
|
+
|
|
191
|
+
if (key === "q" || key === "escape") {
|
|
192
|
+
props.onDismiss();
|
|
193
|
+
return;
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
if (key === "o") {
|
|
197
|
+
setViewStack((prev) => prev.slice(0, -1));
|
|
198
|
+
return;
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
if (key === "i") {
|
|
202
|
+
const hid = highlightedId();
|
|
203
|
+
if (hid) {
|
|
204
|
+
setViewStack((prev) => [...prev, hid]);
|
|
205
|
+
}
|
|
206
|
+
return;
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
if (key === "up" || key === "k") {
|
|
210
|
+
const lines = visibleLines();
|
|
211
|
+
const idx = lines.findIndex((l) => l.messageId === highlightedId());
|
|
212
|
+
if (idx > 0) setHighlightedId(lines[idx - 1].messageId);
|
|
213
|
+
return;
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
if (key === "down" || key === "j") {
|
|
217
|
+
const lines = visibleLines();
|
|
218
|
+
const idx = lines.findIndex((l) => l.messageId === highlightedId());
|
|
219
|
+
if (idx < lines.length - 1) setHighlightedId(lines[idx + 1].messageId);
|
|
220
|
+
return;
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
if (key === "pageup") {
|
|
224
|
+
if (scrollRef) {
|
|
225
|
+
const half = Math.max(1, Math.floor(scrollRef.height / 2));
|
|
226
|
+
scrollRef.scrollBy(-half);
|
|
227
|
+
const lines = visibleLines();
|
|
228
|
+
const newTop = scrollRef.scrollTop;
|
|
229
|
+
const targetLine = lines[newTop];
|
|
230
|
+
if (targetLine) setHighlightedId(targetLine.messageId);
|
|
231
|
+
}
|
|
232
|
+
return;
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
if (key === "pagedown") {
|
|
236
|
+
if (scrollRef) {
|
|
237
|
+
const half = Math.max(1, Math.floor(scrollRef.height / 2));
|
|
238
|
+
scrollRef.scrollBy(half);
|
|
239
|
+
const lines = visibleLines();
|
|
240
|
+
const newTop = scrollRef.scrollTop;
|
|
241
|
+
const targetLine = lines[Math.min(newTop + scrollRef.height - 1, lines.length - 1)];
|
|
242
|
+
if (targetLine) setHighlightedId(targetLine.messageId);
|
|
243
|
+
}
|
|
244
|
+
return;
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
if (key === "backspace") {
|
|
248
|
+
setNumBuffer((prev) => prev.slice(0, -1));
|
|
249
|
+
return;
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
if (key === "return") {
|
|
253
|
+
const buf = numBuffer();
|
|
254
|
+
let targetId: string | undefined;
|
|
255
|
+
|
|
256
|
+
if (buf.length > 0) {
|
|
257
|
+
const num = parseInt(buf, 10);
|
|
258
|
+
const { numToId } = treeData();
|
|
259
|
+
targetId = numToId.get(num);
|
|
260
|
+
if (!targetId) {
|
|
261
|
+
setNavError(`No node at position ${num}`);
|
|
262
|
+
setNumBuffer("");
|
|
263
|
+
return;
|
|
264
|
+
}
|
|
265
|
+
} else {
|
|
266
|
+
const hid = highlightedId();
|
|
267
|
+
const line = visibleLines().find(l => l.messageId === hid);
|
|
268
|
+
if (!line || line.isMaskedOrPlaceholder) return;
|
|
269
|
+
targetId = hid;
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
if (targetId) {
|
|
273
|
+
const targetMsg = props.messages.find(m => m.id === targetId);
|
|
274
|
+
if (targetMsg?.parent_id && incompleteFamilies().has(targetMsg.parent_id)) {
|
|
275
|
+
setNavError(`Node is masked — a sibling response is missing`);
|
|
276
|
+
setNumBuffer("");
|
|
277
|
+
return;
|
|
278
|
+
}
|
|
279
|
+
setNavError("");
|
|
280
|
+
setNumBuffer("");
|
|
281
|
+
void props.onSelectBranch(targetId).then(() => props.onDismiss());
|
|
282
|
+
}
|
|
283
|
+
return;
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
if (key && key.length === 1 && key >= "0" && key <= "9") {
|
|
287
|
+
setNumBuffer((prev) => prev + key);
|
|
288
|
+
return;
|
|
289
|
+
}
|
|
290
|
+
});
|
|
291
|
+
|
|
292
|
+
const headerText = createMemo(() => {
|
|
293
|
+
const name = props.roomName ?? props.roomId;
|
|
294
|
+
return `${name} \u25CF active \u25CB activated \u00B7 unexplored \uD83D\uDD12 masked \u270F\uFE0F your turn`;
|
|
295
|
+
});
|
|
296
|
+
|
|
297
|
+
const footerText = createMemo(() => {
|
|
298
|
+
const err = navError();
|
|
299
|
+
if (err) return `! ${err} (press any key to continue)`;
|
|
300
|
+
const buf = numBuffer();
|
|
301
|
+
const bufPart = buf.length > 0 ? ` | #${buf}_` : "";
|
|
302
|
+
return `[i] zoom in [o] zoom out [q] quit | Type number or highlight + Enter to activate${bufPart}`;
|
|
303
|
+
});
|
|
304
|
+
|
|
305
|
+
return (
|
|
306
|
+
<box
|
|
307
|
+
position="absolute"
|
|
308
|
+
width="100%"
|
|
309
|
+
height="100%"
|
|
310
|
+
left={0}
|
|
311
|
+
top={0}
|
|
312
|
+
backgroundColor="#000000"
|
|
313
|
+
alignItems="center"
|
|
314
|
+
justifyContent="center"
|
|
315
|
+
>
|
|
316
|
+
<box
|
|
317
|
+
width="95%"
|
|
318
|
+
height="95%"
|
|
319
|
+
backgroundColor="#1a1a2e"
|
|
320
|
+
borderStyle="single"
|
|
321
|
+
borderColor="#586e75"
|
|
322
|
+
flexDirection="column"
|
|
323
|
+
>
|
|
324
|
+
<box paddingLeft={1} paddingRight={1} paddingTop={1}>
|
|
325
|
+
<text fg="#eee8d5">{headerText()}</text>
|
|
326
|
+
</box>
|
|
327
|
+
|
|
328
|
+
<scrollbox height="100%" marginTop={1} marginBottom={1} ref={(el: ScrollBoxRenderable) => { scrollRef = el; }}>
|
|
329
|
+
<For each={visibleLines()}>
|
|
330
|
+
{(line) => {
|
|
331
|
+
const isHighlighted = () => line.messageId === highlightedId();
|
|
332
|
+
const fg = () => {
|
|
333
|
+
if (isHighlighted()) return "#b58900";
|
|
334
|
+
if (line.kind === 'masked') return "#44475a";
|
|
335
|
+
if (line.kind === 'your-turn') return "#6272a4";
|
|
336
|
+
return "#93a1a1";
|
|
337
|
+
};
|
|
338
|
+
return (
|
|
339
|
+
<box
|
|
340
|
+
visible={true}
|
|
341
|
+
backgroundColor={isHighlighted() ? "#2d3748" : "transparent"}
|
|
342
|
+
paddingLeft={1}
|
|
343
|
+
>
|
|
344
|
+
<text fg={fg()}>{line.preview}</text>
|
|
345
|
+
</box>
|
|
346
|
+
);
|
|
347
|
+
}}
|
|
348
|
+
</For>
|
|
349
|
+
</scrollbox>
|
|
350
|
+
|
|
351
|
+
<box paddingLeft={1} paddingRight={1} paddingBottom={1}>
|
|
352
|
+
<text fg={navError() ? "#dc322f" : "#586e75"}>{footerText()}</text>
|
|
353
|
+
</box>
|
|
354
|
+
</box>
|
|
355
|
+
</box>
|
|
356
|
+
);
|
|
357
|
+
}
|