@arizeai/phoenix-cli 1.15.1 → 1.16.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/README.md +2 -1
- package/build/pxi/App.d.ts +9 -2
- package/build/pxi/App.d.ts.map +1 -1
- package/build/pxi/App.js +931 -34
- package/build/pxi/App.js.map +1 -1
- package/build/pxi/client.d.ts +36 -21
- package/build/pxi/client.d.ts.map +1 -1
- package/build/pxi/client.js +373 -36
- package/build/pxi/client.js.map +1 -1
- package/build/pxi/commands.d.ts +10 -1
- package/build/pxi/commands.d.ts.map +1 -1
- package/build/pxi/commands.js +27 -2
- package/build/pxi/commands.js.map +1 -1
- package/build/pxi/compaction.d.ts +19 -0
- package/build/pxi/compaction.d.ts.map +1 -0
- package/build/pxi/compaction.js +24 -0
- package/build/pxi/compaction.js.map +1 -0
- package/build/pxi/index.d.ts +5 -5
- package/build/pxi/index.d.ts.map +1 -1
- package/build/pxi/index.js +7 -6
- package/build/pxi/index.js.map +1 -1
- package/build/pxi/options.d.ts +1 -1
- package/build/pxi/options.d.ts.map +1 -1
- package/build/pxi/options.js +5 -1
- package/build/pxi/options.js.map +1 -1
- package/build/pxi/preflight.d.ts +51 -4
- package/build/pxi/preflight.d.ts.map +1 -1
- package/build/pxi/preflight.js +99 -4
- package/build/pxi/preflight.js.map +1 -1
- package/build/pxi/tokenUsage.d.ts +3 -3
- package/build/pxi/tokenUsage.d.ts.map +1 -1
- package/build/pxi/tokenUsage.js +2 -1
- package/build/pxi/tokenUsage.js.map +1 -1
- package/build/pxi/toolPresentation.d.ts.map +1 -1
- package/build/pxi/toolPresentation.js +4 -1
- package/build/pxi/toolPresentation.js.map +1 -1
- package/build/pxi/types.d.ts +116 -58
- package/build/pxi/types.d.ts.map +1 -1
- package/package.json +3 -3
package/build/pxi/App.js
CHANGED
|
@@ -1,10 +1,12 @@
|
|
|
1
1
|
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
2
|
import { Box, Text, useApp, useInput, useStdin } from "ink";
|
|
3
3
|
import { useEffect, useMemo, useRef, useState } from "react";
|
|
4
|
-
import { createPxiChatClient, createUserMessage } from "./client.js";
|
|
4
|
+
import { createPxiChatClient, createPxiSessionClient, createUserMessage, isSessionBusyError, isSessionMessagesStaleError, isSessionModelStaleError, } from "./client.js";
|
|
5
5
|
import { getSlashCommandName, matchingCommands, runSlashCommand, SLASH_COMMANDS, } from "./commands.js";
|
|
6
|
+
import { getCompactionSummary, isCompactionMessage } from "./compaction.js";
|
|
6
7
|
import { deleteDraftTextAtCursor, deleteDraftTextBeforeCursor, EMPTY_DRAFT_EDITOR_STATE, insertDraftText, moveDraftCursor, moveDraftCursorToEnd, moveDraftCursorToStart, moveDraftCursorVertically, } from "./draftEditor.js";
|
|
7
8
|
import { Markdown } from "./inkMarkdown.js";
|
|
9
|
+
import { fetchRecommendedPxiModels, isSameModelSelection, resolveRestoredPxiModelSelection, } from "./preflight.js";
|
|
8
10
|
import { formatTokenUsageLine, getLatestAssistantUsage } from "./tokenUsage.js";
|
|
9
11
|
import { getToolProgressFromPart, } from "./toolProgress.js";
|
|
10
12
|
const PXI_BANNER = String.raw `
|
|
@@ -31,6 +33,19 @@ const RUNNING_TOOL_STATES = new Set([
|
|
|
31
33
|
"input-available",
|
|
32
34
|
"approval-responded",
|
|
33
35
|
]);
|
|
36
|
+
/**
|
|
37
|
+
* Tool states that have not reached a terminal output. The CLI executes no
|
|
38
|
+
* client tools and has no approval affordance, so it can never resolve one of
|
|
39
|
+
* these itself: a pending call either belongs to the turn currently streaming
|
|
40
|
+
* here, is still resolvable by the client that owns it (e.g. a browser
|
|
41
|
+
* approval), or gets closed out as interrupted by the server on this CLI's
|
|
42
|
+
* next send.
|
|
43
|
+
*/
|
|
44
|
+
const PENDING_TOOL_STATES = new Set([
|
|
45
|
+
...RUNNING_TOOL_STATES,
|
|
46
|
+
"approval-requested",
|
|
47
|
+
]);
|
|
48
|
+
const PENDING_ELSEWHERE_TOOL_STATUS_TEXT = "Pending in another client — sending a message here interrupts it";
|
|
34
49
|
const ESCAPE_INPUT = "\x1B";
|
|
35
50
|
const BACKSPACE_INPUTS = new Set(["\b", "\x7F"]);
|
|
36
51
|
const FORWARD_DELETE_INPUTS = new Set([
|
|
@@ -42,6 +57,24 @@ const KITTY_BACKSPACE_INPUT_PATTERN = /^\x1B\[(?:8|127)(?:;\d+(?::[12])?(?:;[\d:
|
|
|
42
57
|
const KITTY_FORWARD_DELETE_INPUT_PATTERN = /^\x1B\[3;\d+:[12]~$/;
|
|
43
58
|
const KEYBOARD_PROTOCOL_RESPONSE_PATTERN = /^\[\?\d+u$/;
|
|
44
59
|
const INTERRUPTED_MESSAGE_TEXT = "\n\n[Interrupted by user before completion.]";
|
|
60
|
+
/** Normal and busy cadences for synchronizing the active session. */
|
|
61
|
+
const SESSION_POLL_INTERVAL_MS = 10_000;
|
|
62
|
+
const SESSION_BUSY_POLL_INTERVAL_MS = 3000;
|
|
63
|
+
const SESSION_BUSY_STATUS_TEXT = "Session is being used elsewhere, the chat will refresh when complete";
|
|
64
|
+
const SESSION_STALE_STATUS_TEXT = "Session was updated elsewhere, the chat has been refreshed";
|
|
65
|
+
/** Names the model so the switch is visible, not just that something changed. */
|
|
66
|
+
const getSessionModelStaleStatusText = ({ modelSelection, }) => `Model was changed elsewhere, this session is now on ${getModelLabel({ modelSelection })}`;
|
|
67
|
+
/**
|
|
68
|
+
* Warns that a restored session's persisted model failed catalog validation.
|
|
69
|
+
* The session stays on that model — swapping in a fallback locally would make
|
|
70
|
+
* every send assert a model the server rejects as stale.
|
|
71
|
+
*/
|
|
72
|
+
const getPersistedModelWarningText = ({ message }) => `Session model may be unavailable: ${message} Use /model to choose a different model.`;
|
|
73
|
+
const COMPACTING_STATUS_TEXT = "Compacting conversation";
|
|
74
|
+
const ALREADY_COMPACT_STATUS_TEXT = "Conversation is already compact. There are no older complete turns to compact.";
|
|
75
|
+
const COMPACT_WHILE_BUSY_ERROR_TEXT = "Session is being used elsewhere. Try again when the other turn completes.";
|
|
76
|
+
const COMPACT_DRAFT_SESSION_ERROR_TEXT = "There is no persisted conversation to compact.";
|
|
77
|
+
const COMPACTION_DIVIDER_TEXT = "── Conversation compacted ──";
|
|
45
78
|
// The 3D banner is drawn with two kinds of strokes: the `\` runs form the
|
|
46
79
|
// raised faces of the letters, while `/` and `_` are the recessed shading.
|
|
47
80
|
// Group each line into runs so the raised strokes can be colored distinctly.
|
|
@@ -67,12 +100,12 @@ function PxiBanner() {
|
|
|
67
100
|
// terminal backgrounds, where plain `blue` washes out.
|
|
68
101
|
color: segment.raised ? "blueBright" : "gray", bold: segment.raised, children: segment.text }, index))) }, lineIndex))) }));
|
|
69
102
|
}
|
|
70
|
-
/** Format
|
|
71
|
-
function getModelLabel(
|
|
72
|
-
if (
|
|
73
|
-
return `custom:${
|
|
103
|
+
/** Format a model selection for display (e.g. `ANTHROPIC/claude-opus-4-8`). */
|
|
104
|
+
function getModelLabel({ modelSelection, }) {
|
|
105
|
+
if (modelSelection.providerType === "custom") {
|
|
106
|
+
return `custom:${modelSelection.providerId}/${modelSelection.modelName}`;
|
|
74
107
|
}
|
|
75
|
-
return `${
|
|
108
|
+
return `${modelSelection.provider}/${modelSelection.modelName}`;
|
|
76
109
|
}
|
|
77
110
|
/** Animated braille spinner shown while a tool call is still in flight. */
|
|
78
111
|
function ToolSpinner() {
|
|
@@ -89,9 +122,14 @@ function ToolSpinner() {
|
|
|
89
122
|
* The leading state glyph of a tool row: a spinner while the call is in
|
|
90
123
|
* flight, then a settled glyph — `✓` success, `✗` failure (including a
|
|
91
124
|
* non-zero exit code surfaced via `statusSuffix`), `?` awaiting approval,
|
|
92
|
-
* `⊘` denied.
|
|
125
|
+
* `⊘` denied. Pending calls this CLI cannot resolve (restored from
|
|
126
|
+
* persistence; the owning client may still submit them) show a warning
|
|
127
|
+
* instead of a spinner.
|
|
93
128
|
*/
|
|
94
|
-
function ToolStateIndicator({ tool }) {
|
|
129
|
+
function ToolStateIndicator({ tool, isPendingElsewhere, }) {
|
|
130
|
+
if (isPendingElsewhere) {
|
|
131
|
+
return _jsx(Text, { color: "yellow", children: "\u26A0" });
|
|
132
|
+
}
|
|
95
133
|
if (RUNNING_TOOL_STATES.has(tool.state)) {
|
|
96
134
|
return _jsx(ToolSpinner, {});
|
|
97
135
|
}
|
|
@@ -114,12 +152,12 @@ function ToolStateIndicator({ tool }) {
|
|
|
114
152
|
* detail lines (e.g. the bash command) and error lines (e.g. stderr).
|
|
115
153
|
* Completed "quiet" tools collapse to a single dim line.
|
|
116
154
|
*/
|
|
117
|
-
function InlineToolProgress({ tool, marginTop, marginBottom, }) {
|
|
155
|
+
function InlineToolProgress({ tool, isPendingElsewhere, marginTop, marginBottom, }) {
|
|
118
156
|
if (tool.isQuiet && tool.state === "output-available") {
|
|
119
157
|
return (_jsx(Box, { paddingLeft: 2, marginTop: marginTop, marginBottom: marginBottom, children: _jsxs(Text, { wrap: "truncate-end", children: [_jsx(Text, { color: "green", children: "\u2713" }), " ", _jsx(Text, { dimColor: true, children: tool.quietLabel ?? tool.toolName })] }) }));
|
|
120
158
|
}
|
|
121
159
|
const showStatusText = tool.state === "approval-requested" || tool.state === "output-denied";
|
|
122
|
-
return (_jsxs(Box, { flexDirection: "column", paddingLeft: 2, marginTop: marginTop, marginBottom: marginBottom, children: [_jsxs(Text, { wrap: "truncate-end", children: [_jsx(ToolStateIndicator, { tool: tool }), " ", _jsxs(Text, { color: "yellow", children: [tool.icon, " ", tool.toolName] }), showStatusText ? _jsxs(Text, { color: "yellow", children: [" ", tool.statusText] }) : null, tool.previewText ? _jsxs(Text, { dimColor: true, children: [" \u00B7 ", tool.previewText] }) : null, tool.statusSuffix ? (_jsxs(Text, { color: "red", children: [" (", tool.statusSuffix, ")"] })) : null] }), tool.detailLines.length > 0 ? (_jsx(Box, { flexDirection: "column", paddingLeft: 4, children: tool.detailLines.map((line, index) => (_jsx(Text, { dimColor: true, wrap: "truncate-end", children: line }, index))) })) : null, tool.errorLines.length > 0 ? (_jsx(Box, { flexDirection: "column", paddingLeft: 4, children: tool.errorLines.map((line, index) => (_jsx(Text, { color: "red", wrap: "truncate-end", children: line }, index))) })) : null] }));
|
|
160
|
+
return (_jsxs(Box, { flexDirection: "column", paddingLeft: 2, marginTop: marginTop, marginBottom: marginBottom, children: [_jsxs(Text, { wrap: "truncate-end", children: [_jsx(ToolStateIndicator, { tool: tool, isPendingElsewhere: isPendingElsewhere }), " ", _jsxs(Text, { color: "yellow", children: [tool.icon, " ", tool.toolName] }), isPendingElsewhere ? (_jsxs(Text, { color: "yellow", children: [" ", PENDING_ELSEWHERE_TOOL_STATUS_TEXT] })) : showStatusText ? (_jsxs(Text, { color: "yellow", children: [" ", tool.statusText] })) : null, tool.previewText ? _jsxs(Text, { dimColor: true, children: [" \u00B7 ", tool.previewText] }) : null, tool.statusSuffix ? (_jsxs(Text, { color: "red", children: [" (", tool.statusSuffix, ")"] })) : null] }), tool.detailLines.length > 0 ? (_jsx(Box, { flexDirection: "column", paddingLeft: 4, children: tool.detailLines.map((line, index) => (_jsx(Text, { dimColor: true, wrap: "truncate-end", children: line }, index))) })) : null, tool.errorLines.length > 0 ? (_jsx(Box, { flexDirection: "column", paddingLeft: 4, children: tool.errorLines.map((line, index) => (_jsx(Text, { color: "red", wrap: "truncate-end", children: line }, index))) })) : null] }));
|
|
123
161
|
}
|
|
124
162
|
/**
|
|
125
163
|
* Render the ordered parts of one message: text parts as markdown, tool parts
|
|
@@ -127,7 +165,7 @@ function InlineToolProgress({ tool, marginTop, marginBottom, }) {
|
|
|
127
165
|
* stack compactly — the blank line appears only at the boundary between a
|
|
128
166
|
* tool block and its neighbors.
|
|
129
167
|
*/
|
|
130
|
-
function MessageParts({ message, phoenixBaseUrl, }) {
|
|
168
|
+
function MessageParts({ message, hasLivePendingTools, phoenixBaseUrl, }) {
|
|
131
169
|
const toolProgressByPart = message.parts.map((part) => getToolProgressFromPart({ part }));
|
|
132
170
|
return (_jsx(Box, { flexDirection: "column", children: message.parts.map((part, index) => {
|
|
133
171
|
if (part.type === "text") {
|
|
@@ -135,7 +173,7 @@ function MessageParts({ message, phoenixBaseUrl, }) {
|
|
|
135
173
|
}
|
|
136
174
|
const tool = toolProgressByPart[index];
|
|
137
175
|
if (tool) {
|
|
138
|
-
return (_jsx(InlineToolProgress, { tool: tool, marginTop: toolProgressByPart[index - 1] ? 0 : 1, marginBottom: toolProgressByPart[index + 1] ? 0 : 1 }, tool.toolCallId));
|
|
176
|
+
return (_jsx(InlineToolProgress, { tool: tool, isPendingElsewhere: !hasLivePendingTools && PENDING_TOOL_STATES.has(tool.state), marginTop: toolProgressByPart[index - 1] ? 0 : 1, marginBottom: toolProgressByPart[index + 1] ? 0 : 1 }, tool.toolCallId));
|
|
139
177
|
}
|
|
140
178
|
return null;
|
|
141
179
|
}) }));
|
|
@@ -144,14 +182,19 @@ function MessageParts({ message, phoenixBaseUrl, }) {
|
|
|
144
182
|
* Render the whole conversation as labeled, color-coded turns ("You" vs "PXI"),
|
|
145
183
|
* or a placeholder when the conversation hasn't started.
|
|
146
184
|
*/
|
|
147
|
-
function Transcript({ messages, phoenixBaseUrl, }) {
|
|
185
|
+
function Transcript({ messages, liveMessageId, phoenixBaseUrl, }) {
|
|
148
186
|
if (messages.length === 0) {
|
|
149
187
|
return _jsx(Text, { dimColor: true, children: "Phoenix Intelligence." });
|
|
150
188
|
}
|
|
151
189
|
return (_jsx(Box, { flexDirection: "column", children: messages.map((message) => {
|
|
190
|
+
if (isCompactionMessage({ message })) {
|
|
191
|
+
// A compaction checkpoint is a persisted summary, not something the
|
|
192
|
+
// user typed: render it as a labeled divider with the summary dimmed.
|
|
193
|
+
return (_jsxs(Box, { flexDirection: "column", marginBottom: 1, children: [_jsx(Text, { color: "yellow", bold: true, children: COMPACTION_DIVIDER_TEXT }), _jsx(Text, { dimColor: true, children: getCompactionSummary({ message }) })] }, message.id));
|
|
194
|
+
}
|
|
152
195
|
const label = message.role === "user" ? "You" : "PXI";
|
|
153
196
|
const color = message.role === "user" ? "cyan" : "green";
|
|
154
|
-
return (_jsxs(Box, { flexDirection: "column", marginBottom: 1, children: [_jsx(Text, { color: color, bold: true, children: label }), _jsx(MessageParts, { message: message, phoenixBaseUrl: phoenixBaseUrl })] }, message.id));
|
|
197
|
+
return (_jsxs(Box, { flexDirection: "column", marginBottom: 1, children: [_jsx(Text, { color: color, bold: true, children: label }), _jsx(MessageParts, { message: message, hasLivePendingTools: message.id === liveMessageId, phoenixBaseUrl: phoenixBaseUrl })] }, message.id));
|
|
155
198
|
}) }));
|
|
156
199
|
}
|
|
157
200
|
/**
|
|
@@ -229,6 +272,60 @@ function InputPrompt({ draft, status, usageLine, modelLabel, }) {
|
|
|
229
272
|
const hints = showHints ? matchingCommands(cmdName) : [];
|
|
230
273
|
return (_jsxs(Box, { flexDirection: "column", marginTop: 1, children: [_jsx(Box, { borderStyle: "single", borderLeft: false, borderRight: false, borderTop: true, borderBottom: true, borderColor: "gray", children: _jsxs(Text, { children: [_jsx(Text, { color: "cyan", children: "❯ " }), _jsx(HighlightedDraft, { draft: draftValue, cursorIndex: draft.cursorIndex, isCursorVisible: status !== "streaming" })] }) }), _jsxs(Box, { flexDirection: "row", justifyContent: "space-between", children: [hints.length > 0 ? (_jsx(Box, { flexDirection: "column", children: hints.map((cmd) => (_jsxs(Text, { children: [_jsx(Text, { color: "yellow", children: " /" }), _jsx(Text, { color: "yellow", bold: true, children: cmd.name }), _jsxs(Text, { dimColor: true, children: [" ", cmd.description] })] }, cmd.name))) })) : (_jsx(Text, { dimColor: true, children: "\u21B5 send \u00B7 \u21E7\u21B5 newline \u00B7 esc interrupt \u00B7 /help \u00B7 ctrl+c exit" })), _jsx(Box, { flexShrink: 0, marginLeft: 2, children: _jsxs(Text, { children: [usageLine ? _jsx(Text, { color: "green", children: usageLine }) : null, usageLine ? _jsx(Text, { dimColor: true, children: " | " }) : null, _jsx(Text, { dimColor: true, children: modelLabel })] }) })] })] }));
|
|
231
274
|
}
|
|
275
|
+
function getFilteredSessions({ sessions, query, }) {
|
|
276
|
+
const normalizedQuery = query.trim().toLowerCase();
|
|
277
|
+
if (!normalizedQuery)
|
|
278
|
+
return sessions;
|
|
279
|
+
return sessions.filter((session) => session.title.toLowerCase().includes(normalizedQuery));
|
|
280
|
+
}
|
|
281
|
+
function getSessionTitle({ title }) {
|
|
282
|
+
return title.trim() || "Untitled session";
|
|
283
|
+
}
|
|
284
|
+
function getFilteredModels({ models, query, }) {
|
|
285
|
+
const normalizedQuery = query.trim().toLowerCase();
|
|
286
|
+
if (!normalizedQuery)
|
|
287
|
+
return models;
|
|
288
|
+
return models.filter((modelSelection) => getModelLabel({ modelSelection }).toLowerCase().includes(normalizedQuery));
|
|
289
|
+
}
|
|
290
|
+
/** Model-selection mode rendered in place of the normal composer. */
|
|
291
|
+
function ModelPicker({ state }) {
|
|
292
|
+
const filteredModels = getFilteredModels({
|
|
293
|
+
models: state.models,
|
|
294
|
+
query: state.query,
|
|
295
|
+
});
|
|
296
|
+
const selectedIndex = Math.min(state.selectedIndex, Math.max(filteredModels.length - 1, 0));
|
|
297
|
+
const firstVisibleIndex = Math.max(0, selectedIndex - 7);
|
|
298
|
+
const visibleModels = filteredModels.slice(firstVisibleIndex, firstVisibleIndex + 8);
|
|
299
|
+
return (_jsxs(Box, { flexDirection: "column", borderStyle: "single", borderColor: "cyan", paddingX: 1, marginTop: 1, children: [_jsx(Text, { bold: true, children: "Recommended models" }), state.status === "loading" ? (_jsx(Text, { dimColor: true, children: "Loading models\u2026" })) : null, state.error ? _jsx(Text, { color: "red", children: state.error }) : null, state.status !== "loading" && state.models.length > 0 ? (_jsxs(Text, { children: [_jsx(Text, { dimColor: true, children: "Filter: " }), state.query, _jsx(Text, { color: "cyan", children: "\u2588" })] })) : null, state.status !== "loading" && filteredModels.length === 0 ? (_jsx(Text, { dimColor: true, children: state.models.length === 0
|
|
300
|
+
? "No recommended models are available."
|
|
301
|
+
: "No models match this filter." })) : null, visibleModels.map((modelSelection, visibleIndex) => {
|
|
302
|
+
const modelIndex = firstVisibleIndex + visibleIndex;
|
|
303
|
+
const isSelected = modelIndex === selectedIndex;
|
|
304
|
+
const label = getModelLabel({ modelSelection });
|
|
305
|
+
return (_jsxs(Text, { color: isSelected ? "cyan" : undefined, children: [isSelected ? "› " : " ", _jsx(Text, { bold: isSelected, children: label })] }, label));
|
|
306
|
+
}), _jsx(Text, { dimColor: true, children: state.status === "error"
|
|
307
|
+
? "esc close and retry"
|
|
308
|
+
: "type to filter · ↑↓ navigate · enter select · esc cancel" })] }));
|
|
309
|
+
}
|
|
310
|
+
/** Session-selection mode rendered in place of the normal composer. */
|
|
311
|
+
function SessionPicker({ state }) {
|
|
312
|
+
const filteredSessions = getFilteredSessions({
|
|
313
|
+
sessions: state.sessions,
|
|
314
|
+
query: state.query,
|
|
315
|
+
});
|
|
316
|
+
const selectedIndex = Math.min(state.selectedIndex, Math.max(filteredSessions.length - 1, 0));
|
|
317
|
+
const firstVisibleIndex = Math.max(0, selectedIndex - 7);
|
|
318
|
+
const visibleSessions = filteredSessions.slice(firstVisibleIndex, firstVisibleIndex + 8);
|
|
319
|
+
return (_jsxs(Box, { flexDirection: "column", borderStyle: "single", borderColor: "cyan", paddingX: 1, marginTop: 1, children: [_jsx(Text, { bold: true, children: "Recent sessions" }), state.status === "loading" ? (_jsx(Text, { dimColor: true, children: "Loading sessions\u2026" })) : null, state.error ? _jsx(Text, { color: "red", children: state.error }) : null, state.status !== "loading" && state.sessions.length > 0 ? (_jsxs(Text, { children: [_jsx(Text, { dimColor: true, children: "Filter: " }), state.query, _jsx(Text, { color: "cyan", children: "\u2588" })] })) : null, state.status !== "loading" && filteredSessions.length === 0 ? (_jsx(Text, { dimColor: true, children: state.sessions.length === 0
|
|
320
|
+
? "No persisted sessions yet."
|
|
321
|
+
: "No sessions match this filter." })) : null, visibleSessions.map((session, visibleIndex) => {
|
|
322
|
+
const sessionIndex = firstVisibleIndex + visibleIndex;
|
|
323
|
+
const isSelected = sessionIndex === selectedIndex;
|
|
324
|
+
return (_jsxs(Text, { color: isSelected ? "cyan" : undefined, children: [isSelected ? "› " : " ", _jsx(Text, { bold: isSelected, children: getSessionTitle({ title: session.title }) }), _jsxs(Text, { dimColor: true, children: [" ", new Date(session.updatedAt).toLocaleString()] })] }, session.id));
|
|
325
|
+
}), _jsx(Text, { dimColor: true, children: state.status === "restoring"
|
|
326
|
+
? "restoring session…"
|
|
327
|
+
: "type to filter · ↑↓ navigate · enter restore · esc cancel" })] }));
|
|
328
|
+
}
|
|
232
329
|
function isKeyboardProtocolResponseInput({ input }) {
|
|
233
330
|
return KEYBOARD_PROTOCOL_RESPONSE_PATTERN.test(input);
|
|
234
331
|
}
|
|
@@ -293,6 +390,20 @@ export function ThinkingIndicator() {
|
|
|
293
390
|
}, []);
|
|
294
391
|
return _jsx(Text, { color: "yellow", children: THINKING_FRAMES[frameIndex] });
|
|
295
392
|
}
|
|
393
|
+
/**
|
|
394
|
+
* Spinner and status line for long-running session states — another client's
|
|
395
|
+
* turn holding the server-side lock, or an in-flight compaction.
|
|
396
|
+
*/
|
|
397
|
+
function StatusSpinnerLine({ text }) {
|
|
398
|
+
const [frameIndex, setFrameIndex] = useState(0);
|
|
399
|
+
useEffect(() => {
|
|
400
|
+
const interval = setInterval(() => {
|
|
401
|
+
setFrameIndex((value) => (value + 1) % TOOL_SPINNER_FRAMES.length);
|
|
402
|
+
}, 250);
|
|
403
|
+
return () => clearInterval(interval);
|
|
404
|
+
}, []);
|
|
405
|
+
return (_jsxs(Text, { color: "yellow", children: [TOOL_SPINNER_FRAMES[frameIndex], " ", text] }));
|
|
406
|
+
}
|
|
296
407
|
/**
|
|
297
408
|
* Root component for the PXI chat.
|
|
298
409
|
*
|
|
@@ -304,7 +415,7 @@ export function ThinkingIndicator() {
|
|
|
304
415
|
* `initialMessages` props exist mainly so tests can drive the UI with a fake
|
|
305
416
|
* client and seeded history.
|
|
306
417
|
*/
|
|
307
|
-
export function PxiApp({ options, client, initialMessages = [] }) {
|
|
418
|
+
export function PxiApp({ options, client, clientFactory, modelLoader, sessionClient, sessionModelResolver, initialMessages = [], }) {
|
|
308
419
|
const { exit } = useApp();
|
|
309
420
|
// ink v7 narrowed useStdin()'s return type to its public props, but the
|
|
310
421
|
// context value still carries the internal raw-input emitter this app
|
|
@@ -315,9 +426,162 @@ export function PxiApp({ options, client, initialMessages = [] }) {
|
|
|
315
426
|
const [draft, setDraft] = useState(EMPTY_DRAFT_EDITOR_STATE);
|
|
316
427
|
const [status, setStatus] = useState("idle");
|
|
317
428
|
const [error, setError] = useState(null);
|
|
429
|
+
// Another client's turn holds the session's server-side lock (HTTP 409 on
|
|
430
|
+
// send, or `isActive` on restore); plain sends are blocked while set.
|
|
431
|
+
const [isSessionBusy, setIsSessionBusy] = useState(false);
|
|
432
|
+
// A send was rejected as stale (another client appended to the session) and
|
|
433
|
+
// the transcript was refreshed in place; shown until the next send.
|
|
434
|
+
const [showStaleRefreshNotice, setShowStaleRefreshNotice] = useState(false);
|
|
435
|
+
// A send or compaction was rejected because another client moved the session
|
|
436
|
+
// to a different model. Tracked separately from the transcript notice so the
|
|
437
|
+
// user is told what actually changed; shown until the next send.
|
|
438
|
+
const [showModelStaleNotice, setShowModelStaleNotice] = useState(false);
|
|
439
|
+
// A restored session's persisted model failed catalog validation (e.g. its
|
|
440
|
+
// provider lost credentials). The model is kept anyway — the server record
|
|
441
|
+
// is the source of truth — so this warning is what tells the user; shown
|
|
442
|
+
// until the next send or model pick.
|
|
443
|
+
const [modelValidationWarning, setModelValidationWarning] = useState(null);
|
|
444
|
+
// A compaction request is in flight; plain sends are blocked while set.
|
|
445
|
+
const [isCompacting, setIsCompacting] = useState(false);
|
|
446
|
+
// One-shot notice after a no-op compaction; shown until the next send.
|
|
447
|
+
const [compactionNotice, setCompactionNotice] = useState(null);
|
|
448
|
+
const [activeSession, setActiveSession] = useState(null);
|
|
449
|
+
const [activeModelSelection, setActiveModelSelection] = useState(options.modelSelection);
|
|
450
|
+
const [isDraftTemporary, setIsDraftTemporary] = useState(false);
|
|
451
|
+
const [modelPicker, setModelPicker] = useState(null);
|
|
452
|
+
const [sessionPicker, setSessionPicker] = useState(null);
|
|
318
453
|
const abortControllerRef = useRef(null);
|
|
319
454
|
const streamingAssistantMessageRef = useRef(null);
|
|
320
|
-
const
|
|
455
|
+
const modelRequestIdRef = useRef(0);
|
|
456
|
+
const sessionRequestIdRef = useRef(0);
|
|
457
|
+
/**
|
|
458
|
+
* Number of model writes awaiting acknowledgement. While non-zero the poll
|
|
459
|
+
* leaves the model alone: it refetches server state that may predate the
|
|
460
|
+
* user's pick, and applying it would revert the selection mid-flight.
|
|
461
|
+
*/
|
|
462
|
+
const modelWriteCountRef = useRef(0);
|
|
463
|
+
/**
|
|
464
|
+
* Latest displayed selection, read by the poll so it can detect a remote
|
|
465
|
+
* model change without taking `activeModelSelection` as an effect
|
|
466
|
+
* dependency — that would restart the poll interval on every switch.
|
|
467
|
+
*/
|
|
468
|
+
const activeModelSelectionRef = useRef(activeModelSelection);
|
|
469
|
+
activeModelSelectionRef.current = activeModelSelection;
|
|
470
|
+
/**
|
|
471
|
+
* Last successfully fetched session list. Lets the picker open instantly
|
|
472
|
+
* with the previous list while a background refresh fetches the latest —
|
|
473
|
+
* the terminal equivalent of the web menu's store-and-network refetch.
|
|
474
|
+
*/
|
|
475
|
+
const cachedSessionListRef = useRef(null);
|
|
476
|
+
/**
|
|
477
|
+
* The persisted transcript's tail as of the last applied full fetch.
|
|
478
|
+
* Polling probes the cheap sync state first and skips the full transcript
|
|
479
|
+
* fetch while the tail hasn't moved, so idle sessions cost a tiny metadata
|
|
480
|
+
* read instead of re-downloading every message.
|
|
481
|
+
*/
|
|
482
|
+
const lastSyncedSessionStateRef = useRef(null);
|
|
483
|
+
/** Records the applied transcript's tail after a full session fetch. */
|
|
484
|
+
const recordSyncedSessionState = (session) => {
|
|
485
|
+
lastSyncedSessionStateRef.current = {
|
|
486
|
+
sessionId: session.id,
|
|
487
|
+
updatedAt: session.updatedAt,
|
|
488
|
+
lastMessageId: session.lastMessageId ?? null,
|
|
489
|
+
};
|
|
490
|
+
};
|
|
491
|
+
const serverSessionClient = useMemo(() => sessionClient ?? createPxiSessionClient({ config: options.config }), [options.config, sessionClient]);
|
|
492
|
+
const resolveSessionModel = useMemo(() => sessionModelResolver ??
|
|
493
|
+
((model) => resolveRestoredPxiModelSelection({
|
|
494
|
+
options,
|
|
495
|
+
persistedModelSelection: model,
|
|
496
|
+
onInvalidModel: ({ error }) => {
|
|
497
|
+
// A /model pick in flight supersedes the model this resolve read;
|
|
498
|
+
// its warning would describe a model the user already left.
|
|
499
|
+
if (modelWriteCountRef.current > 0) {
|
|
500
|
+
return;
|
|
501
|
+
}
|
|
502
|
+
setModelValidationWarning(getPersistedModelWarningText({ message: error.message }));
|
|
503
|
+
},
|
|
504
|
+
})), [options, sessionModelResolver]);
|
|
505
|
+
// Keep the active session synchronized with turns completed by other
|
|
506
|
+
// clients. Each tick fetches the cheap sync probe (isActive + transcript
|
|
507
|
+
// tail) and only downloads the full transcript when the tail has moved
|
|
508
|
+
// since the last applied fetch. Poll slowly during normal use and switch
|
|
509
|
+
// to the existing faster cadence while another client holds the turn lock.
|
|
510
|
+
// This client's own generation disables polling so its in-flight messages
|
|
511
|
+
// cannot be replaced by an older persisted transcript.
|
|
512
|
+
const activeSessionId = activeSession?.id ?? null;
|
|
513
|
+
const isSessionPollingPaused = status === "streaming" || isCompacting;
|
|
514
|
+
useEffect(() => {
|
|
515
|
+
if (!activeSessionId || isSessionPollingPaused) {
|
|
516
|
+
return undefined;
|
|
517
|
+
}
|
|
518
|
+
let isStale = false;
|
|
519
|
+
const pollSession = () => {
|
|
520
|
+
void serverSessionClient
|
|
521
|
+
.getSessionSyncState({ sessionId: activeSessionId })
|
|
522
|
+
.then(async (syncState) => {
|
|
523
|
+
if (isStale) {
|
|
524
|
+
return;
|
|
525
|
+
}
|
|
526
|
+
if (syncState.isActive) {
|
|
527
|
+
setIsSessionBusy(true);
|
|
528
|
+
return;
|
|
529
|
+
}
|
|
530
|
+
const lastSynced = lastSyncedSessionStateRef.current;
|
|
531
|
+
const isTranscriptUnchanged = lastSynced != null &&
|
|
532
|
+
lastSynced.sessionId === activeSessionId &&
|
|
533
|
+
lastSynced.updatedAt === syncState.updatedAt &&
|
|
534
|
+
lastSynced.lastMessageId === syncState.lastMessageId;
|
|
535
|
+
if (isTranscriptUnchanged && !isSessionBusy) {
|
|
536
|
+
return;
|
|
537
|
+
}
|
|
538
|
+
const session = await serverSessionClient.getSession({
|
|
539
|
+
sessionId: activeSessionId,
|
|
540
|
+
});
|
|
541
|
+
if (isStale) {
|
|
542
|
+
return;
|
|
543
|
+
}
|
|
544
|
+
if (session.isActive) {
|
|
545
|
+
// Another client claimed the turn lock between the probe and the
|
|
546
|
+
// full fetch; treat this tick as busy and resync on the next one.
|
|
547
|
+
setIsSessionBusy(true);
|
|
548
|
+
return;
|
|
549
|
+
}
|
|
550
|
+
recordSyncedSessionState(session);
|
|
551
|
+
// Only re-resolve when the session actually moved to a different
|
|
552
|
+
// model: resolving validates against the server's model catalog,
|
|
553
|
+
// and doing that on every tick would put a network round trip on
|
|
554
|
+
// the poll's steady state.
|
|
555
|
+
const hasModelChanged = !isSameModelSelection(session.model, activeModelSelectionRef.current);
|
|
556
|
+
const restoredModel = hasModelChanged
|
|
557
|
+
? await resolveSessionModel(session.model)
|
|
558
|
+
: activeModelSelectionRef.current;
|
|
559
|
+
if (isStale) {
|
|
560
|
+
return;
|
|
561
|
+
}
|
|
562
|
+
setActiveSession(session);
|
|
563
|
+
if (hasModelChanged && modelWriteCountRef.current === 0) {
|
|
564
|
+
setActiveModelSelection(restoredModel);
|
|
565
|
+
}
|
|
566
|
+
setMessages(session.messages);
|
|
567
|
+
setIsSessionBusy(false);
|
|
568
|
+
})
|
|
569
|
+
.catch(() => {
|
|
570
|
+
// Transient failure: wait for the next poll tick.
|
|
571
|
+
});
|
|
572
|
+
};
|
|
573
|
+
const intervalId = setInterval(pollSession, isSessionBusy ? SESSION_BUSY_POLL_INTERVAL_MS : SESSION_POLL_INTERVAL_MS);
|
|
574
|
+
return () => {
|
|
575
|
+
isStale = true;
|
|
576
|
+
clearInterval(intervalId);
|
|
577
|
+
};
|
|
578
|
+
}, [
|
|
579
|
+
activeSessionId,
|
|
580
|
+
isSessionPollingPaused,
|
|
581
|
+
isSessionBusy,
|
|
582
|
+
resolveSessionModel,
|
|
583
|
+
serverSessionClient,
|
|
584
|
+
]);
|
|
321
585
|
const handleExit = () => {
|
|
322
586
|
abortControllerRef.current?.abort();
|
|
323
587
|
exit();
|
|
@@ -343,11 +607,360 @@ export function PxiApp({ options, client, initialMessages = [] }) {
|
|
|
343
607
|
}
|
|
344
608
|
setStatus("idle");
|
|
345
609
|
};
|
|
346
|
-
const
|
|
610
|
+
const startNewSession = ({ temporary }) => {
|
|
611
|
+
modelRequestIdRef.current += 1;
|
|
612
|
+
sessionRequestIdRef.current += 1;
|
|
613
|
+
setActiveSession(null);
|
|
614
|
+
lastSyncedSessionStateRef.current = null;
|
|
615
|
+
setActiveModelSelection(options.modelSelection);
|
|
616
|
+
setIsDraftTemporary(temporary);
|
|
617
|
+
setIsSessionBusy(false);
|
|
618
|
+
setShowStaleRefreshNotice(false);
|
|
619
|
+
setShowModelStaleNotice(false);
|
|
620
|
+
setCompactionNotice(null);
|
|
621
|
+
setModelValidationWarning(null);
|
|
622
|
+
setModelPicker(null);
|
|
623
|
+
setSessionPicker(null);
|
|
347
624
|
setMessages([]);
|
|
348
625
|
setError(null);
|
|
349
626
|
setDraft(EMPTY_DRAFT_EDITOR_STATE);
|
|
350
627
|
};
|
|
628
|
+
const closeSessionPicker = () => {
|
|
629
|
+
sessionRequestIdRef.current += 1;
|
|
630
|
+
setSessionPicker(null);
|
|
631
|
+
};
|
|
632
|
+
const openSessionPicker = () => {
|
|
633
|
+
modelRequestIdRef.current += 1;
|
|
634
|
+
setModelPicker(null);
|
|
635
|
+
const requestId = sessionRequestIdRef.current + 1;
|
|
636
|
+
sessionRequestIdRef.current = requestId;
|
|
637
|
+
setDraft(EMPTY_DRAFT_EDITOR_STATE);
|
|
638
|
+
setError(null);
|
|
639
|
+
// Open instantly with the last fetched list (when there is one) while a
|
|
640
|
+
// background refresh fetches sessions created elsewhere in the meantime.
|
|
641
|
+
const cachedSessions = cachedSessionListRef.current;
|
|
642
|
+
setSessionPicker({
|
|
643
|
+
status: cachedSessions ? "ready" : "loading",
|
|
644
|
+
sessions: cachedSessions ?? [],
|
|
645
|
+
query: "",
|
|
646
|
+
selectedIndex: 0,
|
|
647
|
+
error: null,
|
|
648
|
+
});
|
|
649
|
+
void serverSessionClient
|
|
650
|
+
.listSessions()
|
|
651
|
+
.then((sessions) => {
|
|
652
|
+
cachedSessionListRef.current = sessions;
|
|
653
|
+
if (sessionRequestIdRef.current !== requestId)
|
|
654
|
+
return;
|
|
655
|
+
setSessionPicker((current) => {
|
|
656
|
+
if (!current)
|
|
657
|
+
return null;
|
|
658
|
+
// The user may already be filtering or navigating the cached list;
|
|
659
|
+
// keep their selection on the same session after the refresh.
|
|
660
|
+
const selectedSession = getFilteredSessions({
|
|
661
|
+
sessions: current.sessions,
|
|
662
|
+
query: current.query,
|
|
663
|
+
})[current.selectedIndex];
|
|
664
|
+
const refreshedFilteredSessions = getFilteredSessions({
|
|
665
|
+
sessions,
|
|
666
|
+
query: current.query,
|
|
667
|
+
});
|
|
668
|
+
const refreshedSelectedIndex = selectedSession
|
|
669
|
+
? refreshedFilteredSessions.findIndex((session) => session.id === selectedSession.id)
|
|
670
|
+
: -1;
|
|
671
|
+
return {
|
|
672
|
+
...current,
|
|
673
|
+
status: "ready",
|
|
674
|
+
sessions,
|
|
675
|
+
selectedIndex: refreshedSelectedIndex === -1
|
|
676
|
+
? Math.min(current.selectedIndex, Math.max(refreshedFilteredSessions.length - 1, 0))
|
|
677
|
+
: refreshedSelectedIndex,
|
|
678
|
+
error: null,
|
|
679
|
+
};
|
|
680
|
+
});
|
|
681
|
+
})
|
|
682
|
+
.catch((sessionError) => {
|
|
683
|
+
if (sessionRequestIdRef.current !== requestId)
|
|
684
|
+
return;
|
|
685
|
+
// With a cached list on screen the stale data is still usable, so a
|
|
686
|
+
// failed background refresh is ignored.
|
|
687
|
+
if (cachedSessions)
|
|
688
|
+
return;
|
|
689
|
+
setSessionPicker({
|
|
690
|
+
status: "error",
|
|
691
|
+
sessions: [],
|
|
692
|
+
query: "",
|
|
693
|
+
selectedIndex: 0,
|
|
694
|
+
error: sessionError instanceof Error
|
|
695
|
+
? sessionError.message
|
|
696
|
+
: String(sessionError),
|
|
697
|
+
});
|
|
698
|
+
});
|
|
699
|
+
};
|
|
700
|
+
const closeModelPicker = () => {
|
|
701
|
+
modelRequestIdRef.current += 1;
|
|
702
|
+
setModelPicker(null);
|
|
703
|
+
};
|
|
704
|
+
const openModelPicker = () => {
|
|
705
|
+
sessionRequestIdRef.current += 1;
|
|
706
|
+
setSessionPicker(null);
|
|
707
|
+
const requestId = modelRequestIdRef.current + 1;
|
|
708
|
+
modelRequestIdRef.current = requestId;
|
|
709
|
+
setDraft(EMPTY_DRAFT_EDITOR_STATE);
|
|
710
|
+
setError(null);
|
|
711
|
+
setModelPicker({
|
|
712
|
+
status: "loading",
|
|
713
|
+
models: [],
|
|
714
|
+
query: "",
|
|
715
|
+
selectedIndex: 0,
|
|
716
|
+
error: null,
|
|
717
|
+
});
|
|
718
|
+
const loadModels = modelLoader ??
|
|
719
|
+
(() => fetchRecommendedPxiModels({ config: options.config }));
|
|
720
|
+
void loadModels()
|
|
721
|
+
.then((models) => {
|
|
722
|
+
if (modelRequestIdRef.current !== requestId)
|
|
723
|
+
return;
|
|
724
|
+
setModelPicker({
|
|
725
|
+
status: "ready",
|
|
726
|
+
models,
|
|
727
|
+
query: "",
|
|
728
|
+
selectedIndex: 0,
|
|
729
|
+
error: null,
|
|
730
|
+
});
|
|
731
|
+
})
|
|
732
|
+
.catch((modelError) => {
|
|
733
|
+
if (modelRequestIdRef.current !== requestId)
|
|
734
|
+
return;
|
|
735
|
+
setModelPicker({
|
|
736
|
+
status: "error",
|
|
737
|
+
models: [],
|
|
738
|
+
query: "",
|
|
739
|
+
selectedIndex: 0,
|
|
740
|
+
error: modelError instanceof Error
|
|
741
|
+
? modelError.message
|
|
742
|
+
: String(modelError),
|
|
743
|
+
});
|
|
744
|
+
});
|
|
745
|
+
};
|
|
746
|
+
const selectModel = () => {
|
|
747
|
+
if (!modelPicker || modelPicker.status !== "ready")
|
|
748
|
+
return;
|
|
749
|
+
const filteredModels = getFilteredModels({
|
|
750
|
+
models: modelPicker.models,
|
|
751
|
+
query: modelPicker.query,
|
|
752
|
+
});
|
|
753
|
+
const selectedModel = filteredModels[modelPicker.selectedIndex];
|
|
754
|
+
if (!selectedModel)
|
|
755
|
+
return;
|
|
756
|
+
const previousModel = activeModelSelection;
|
|
757
|
+
setActiveModelSelection(selectedModel);
|
|
758
|
+
setModelPicker(null);
|
|
759
|
+
setError(null);
|
|
760
|
+
// The pick replaces whatever model the warning was about.
|
|
761
|
+
setModelValidationWarning(null);
|
|
762
|
+
// A draft has no server record yet; its selection travels with the
|
|
763
|
+
// createSession call that starts the session.
|
|
764
|
+
const persistedSessionId = activeSession?.id;
|
|
765
|
+
if (!persistedSessionId)
|
|
766
|
+
return;
|
|
767
|
+
// Held across the write so the poll cannot apply a server read that
|
|
768
|
+
// predates it and revert the pick.
|
|
769
|
+
modelWriteCountRef.current += 1;
|
|
770
|
+
const requestId = sessionRequestIdRef.current;
|
|
771
|
+
void serverSessionClient
|
|
772
|
+
.patchSessionModel({
|
|
773
|
+
sessionId: persistedSessionId,
|
|
774
|
+
model: selectedModel,
|
|
775
|
+
})
|
|
776
|
+
.then((model) => {
|
|
777
|
+
if (sessionRequestIdRef.current !== requestId)
|
|
778
|
+
return;
|
|
779
|
+
setActiveModelSelection(model);
|
|
780
|
+
})
|
|
781
|
+
.catch((err) => {
|
|
782
|
+
if (sessionRequestIdRef.current !== requestId)
|
|
783
|
+
return;
|
|
784
|
+
setActiveModelSelection(previousModel);
|
|
785
|
+
setError(err instanceof Error ? err.message : String(err));
|
|
786
|
+
})
|
|
787
|
+
.finally(() => {
|
|
788
|
+
modelWriteCountRef.current -= 1;
|
|
789
|
+
});
|
|
790
|
+
};
|
|
791
|
+
const restoreSelectedSession = () => {
|
|
792
|
+
if (!sessionPicker || sessionPicker.status !== "ready")
|
|
793
|
+
return;
|
|
794
|
+
const filteredSessions = getFilteredSessions({
|
|
795
|
+
sessions: sessionPicker.sessions,
|
|
796
|
+
query: sessionPicker.query,
|
|
797
|
+
});
|
|
798
|
+
const selectedSession = filteredSessions[sessionPicker.selectedIndex];
|
|
799
|
+
if (!selectedSession)
|
|
800
|
+
return;
|
|
801
|
+
const requestId = sessionRequestIdRef.current + 1;
|
|
802
|
+
sessionRequestIdRef.current = requestId;
|
|
803
|
+
setSessionPicker((current) => current ? { ...current, status: "restoring", error: null } : null);
|
|
804
|
+
void serverSessionClient
|
|
805
|
+
.getSession({ sessionId: selectedSession.id })
|
|
806
|
+
.then(async (session) => {
|
|
807
|
+
if (sessionRequestIdRef.current !== requestId)
|
|
808
|
+
return;
|
|
809
|
+
// Cleared before the resolve so a warning it raises about this
|
|
810
|
+
// session's model survives, while one from a previous session does
|
|
811
|
+
// not.
|
|
812
|
+
setModelValidationWarning(null);
|
|
813
|
+
// Explicit --provider/--model express an intent to move the session
|
|
814
|
+
// onto that model, so restoring writes it rather than shadowing the
|
|
815
|
+
// persisted value locally. Applied once here, not on every poll —
|
|
816
|
+
// and skipped when the session is already on that model, which would
|
|
817
|
+
// make the write a no-op round trip that still bumps the session's
|
|
818
|
+
// updated_at and reorders the session list.
|
|
819
|
+
const shouldWriteExplicitModel = options.hasExplicitModelSelection &&
|
|
820
|
+
!isSameModelSelection(session.model, options.modelSelection);
|
|
821
|
+
const restoredModel = shouldWriteExplicitModel
|
|
822
|
+
? await serverSessionClient.patchSessionModel({
|
|
823
|
+
sessionId: session.id,
|
|
824
|
+
model: options.modelSelection,
|
|
825
|
+
})
|
|
826
|
+
: await resolveSessionModel(session.model);
|
|
827
|
+
if (sessionRequestIdRef.current !== requestId)
|
|
828
|
+
return;
|
|
829
|
+
recordSyncedSessionState(session);
|
|
830
|
+
setActiveSession(session);
|
|
831
|
+
setActiveModelSelection(restoredModel);
|
|
832
|
+
setIsDraftTemporary(session.isTemporary);
|
|
833
|
+
// Another client's turn may already hold the restored session's lock;
|
|
834
|
+
// enter the busy state so the poll refreshes the transcript when it
|
|
835
|
+
// completes.
|
|
836
|
+
setIsSessionBusy(session.isActive === true);
|
|
837
|
+
setShowStaleRefreshNotice(false);
|
|
838
|
+
setShowModelStaleNotice(false);
|
|
839
|
+
setCompactionNotice(null);
|
|
840
|
+
setMessages(session.messages);
|
|
841
|
+
setError(null);
|
|
842
|
+
setDraft(EMPTY_DRAFT_EDITOR_STATE);
|
|
843
|
+
setSessionPicker(null);
|
|
844
|
+
})
|
|
845
|
+
.catch((sessionError) => {
|
|
846
|
+
if (sessionRequestIdRef.current !== requestId)
|
|
847
|
+
return;
|
|
848
|
+
setSessionPicker((current) => current
|
|
849
|
+
? {
|
|
850
|
+
...current,
|
|
851
|
+
status: "error",
|
|
852
|
+
error: sessionError instanceof Error
|
|
853
|
+
? sessionError.message
|
|
854
|
+
: String(sessionError),
|
|
855
|
+
}
|
|
856
|
+
: null);
|
|
857
|
+
});
|
|
858
|
+
};
|
|
859
|
+
/**
|
|
860
|
+
* Compact the persisted conversation into a checkpoint summary. Mirrors the
|
|
861
|
+
* web UI's `/compact`: rejected while the session is busy elsewhere, shows a
|
|
862
|
+
* one-shot notice when there is nothing to compact, and sends `pendingText`
|
|
863
|
+
* as a follow-up message once the compaction lands.
|
|
864
|
+
*/
|
|
865
|
+
const compactSession = (pendingText) => {
|
|
866
|
+
setCompactionNotice(null);
|
|
867
|
+
const restorePendingText = () => {
|
|
868
|
+
if (pendingText) {
|
|
869
|
+
setDraft((currentDraft) => currentDraft.value
|
|
870
|
+
? currentDraft
|
|
871
|
+
: { value: pendingText, cursorIndex: pendingText.length });
|
|
872
|
+
}
|
|
873
|
+
};
|
|
874
|
+
if (!activeSession) {
|
|
875
|
+
restorePendingText();
|
|
876
|
+
setError(COMPACT_DRAFT_SESSION_ERROR_TEXT);
|
|
877
|
+
return;
|
|
878
|
+
}
|
|
879
|
+
if (isCompacting) {
|
|
880
|
+
restorePendingText();
|
|
881
|
+
return;
|
|
882
|
+
}
|
|
883
|
+
if (isSessionBusy) {
|
|
884
|
+
restorePendingText();
|
|
885
|
+
setError(COMPACT_WHILE_BUSY_ERROR_TEXT);
|
|
886
|
+
return;
|
|
887
|
+
}
|
|
888
|
+
const compactionSessionId = activeSession.id;
|
|
889
|
+
// A session switch (/new, restore) mid-compaction supersedes this request.
|
|
890
|
+
const requestId = sessionRequestIdRef.current;
|
|
891
|
+
const baseMessages = messages;
|
|
892
|
+
setError(null);
|
|
893
|
+
setIsCompacting(true);
|
|
894
|
+
void serverSessionClient
|
|
895
|
+
.compactSession({
|
|
896
|
+
sessionId: compactionSessionId,
|
|
897
|
+
model: activeModelSelection,
|
|
898
|
+
})
|
|
899
|
+
.then((result) => {
|
|
900
|
+
if (sessionRequestIdRef.current !== requestId)
|
|
901
|
+
return;
|
|
902
|
+
const checkpoint = result.compactionMessage;
|
|
903
|
+
const nextMessages = checkpoint &&
|
|
904
|
+
!baseMessages.some((message) => message.id === checkpoint.id)
|
|
905
|
+
? [...baseMessages, checkpoint]
|
|
906
|
+
: baseMessages;
|
|
907
|
+
setMessages(nextMessages);
|
|
908
|
+
if (!result.compacted) {
|
|
909
|
+
setCompactionNotice(ALREADY_COMPACT_STATUS_TEXT);
|
|
910
|
+
}
|
|
911
|
+
if (pendingText) {
|
|
912
|
+
sendUserText({ text: pendingText, baseMessages: nextMessages });
|
|
913
|
+
}
|
|
914
|
+
})
|
|
915
|
+
.catch((compactError) => {
|
|
916
|
+
if (sessionRequestIdRef.current !== requestId)
|
|
917
|
+
return;
|
|
918
|
+
restorePendingText();
|
|
919
|
+
if (isSessionBusyError({ error: compactError })) {
|
|
920
|
+
// Another client's turn holds the session lock (HTTP 409): enter
|
|
921
|
+
// the busy state; the poll refreshes the transcript once the other
|
|
922
|
+
// turn completes.
|
|
923
|
+
setIsSessionBusy(true);
|
|
924
|
+
return;
|
|
925
|
+
}
|
|
926
|
+
if (isSessionModelStaleError({ error: compactError })) {
|
|
927
|
+
// Another client moved the session to a different model (HTTP 409).
|
|
928
|
+
// No summary was generated; refresh so the header and the next
|
|
929
|
+
// request agree with the server, and say what changed.
|
|
930
|
+
const staleSessionId = activeSession?.id;
|
|
931
|
+
if (!staleSessionId) {
|
|
932
|
+
return;
|
|
933
|
+
}
|
|
934
|
+
void serverSessionClient
|
|
935
|
+
.getSession({ sessionId: staleSessionId })
|
|
936
|
+
.then(async (session) => {
|
|
937
|
+
if (sessionRequestIdRef.current !== requestId)
|
|
938
|
+
return;
|
|
939
|
+
const restoredModel = await resolveSessionModel(session.model);
|
|
940
|
+
if (sessionRequestIdRef.current !== requestId)
|
|
941
|
+
return;
|
|
942
|
+
// A 409 racing the user's own /model write must not flip the
|
|
943
|
+
// header back to the old model or announce the reverse of what
|
|
944
|
+
// the user did: the in-flight write re-applies their pick and
|
|
945
|
+
// the poll reconciles any residue.
|
|
946
|
+
if (modelWriteCountRef.current > 0)
|
|
947
|
+
return;
|
|
948
|
+
setActiveModelSelection(restoredModel);
|
|
949
|
+
setShowModelStaleNotice(true);
|
|
950
|
+
})
|
|
951
|
+
.catch(() => {
|
|
952
|
+
// Refresh failed; the next compaction hits the same rejection.
|
|
953
|
+
});
|
|
954
|
+
return;
|
|
955
|
+
}
|
|
956
|
+
setError(compactError instanceof Error
|
|
957
|
+
? compactError.message
|
|
958
|
+
: String(compactError));
|
|
959
|
+
})
|
|
960
|
+
.finally(() => {
|
|
961
|
+
setIsCompacting(false);
|
|
962
|
+
});
|
|
963
|
+
};
|
|
351
964
|
const submitDraft = () => {
|
|
352
965
|
const text = draft.value.trim();
|
|
353
966
|
if (!text || status === "streaming") {
|
|
@@ -357,7 +970,10 @@ export function PxiApp({ options, client, initialMessages = [] }) {
|
|
|
357
970
|
if (text.startsWith("/")) {
|
|
358
971
|
setDraft(EMPTY_DRAFT_EDITOR_STATE);
|
|
359
972
|
const result = runSlashCommand(text, {
|
|
360
|
-
|
|
973
|
+
startNewSession,
|
|
974
|
+
openModelPicker,
|
|
975
|
+
openSessionPicker,
|
|
976
|
+
compactSession,
|
|
361
977
|
exit: handleExit,
|
|
362
978
|
});
|
|
363
979
|
if (result.type === "help") {
|
|
@@ -381,24 +997,70 @@ export function PxiApp({ options, client, initialMessages = [] }) {
|
|
|
381
997
|
}
|
|
382
998
|
return;
|
|
383
999
|
}
|
|
1000
|
+
// Plain sends are blocked while another client's turn holds the session
|
|
1001
|
+
// lock or a compaction is in flight; keep the draft editable so nothing
|
|
1002
|
+
// typed is lost.
|
|
1003
|
+
if (isSessionBusy || isCompacting) {
|
|
1004
|
+
return;
|
|
1005
|
+
}
|
|
1006
|
+
setDraft(EMPTY_DRAFT_EDITOR_STATE);
|
|
1007
|
+
sendUserText({ text, baseMessages: messages });
|
|
1008
|
+
};
|
|
1009
|
+
/** Append a user message to `baseMessages` and stream the reply. */
|
|
1010
|
+
const sendUserText = ({ text, baseMessages, }) => {
|
|
384
1011
|
const userMessage = createUserMessage({ text });
|
|
385
|
-
const nextMessages = [...
|
|
1012
|
+
const nextMessages = [...baseMessages, userMessage];
|
|
386
1013
|
const abortController = new AbortController();
|
|
387
1014
|
abortControllerRef.current = abortController;
|
|
388
1015
|
streamingAssistantMessageRef.current = null;
|
|
389
|
-
setDraft(EMPTY_DRAFT_EDITOR_STATE);
|
|
390
1016
|
setError(null);
|
|
1017
|
+
setShowStaleRefreshNotice(false);
|
|
1018
|
+
setShowModelStaleNotice(false);
|
|
1019
|
+
setCompactionNotice(null);
|
|
1020
|
+
setModelValidationWarning(null);
|
|
391
1021
|
setStatus("streaming");
|
|
392
1022
|
setMessages(nextMessages);
|
|
393
|
-
void
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
1023
|
+
void (async () => {
|
|
1024
|
+
let resolvedClient = client;
|
|
1025
|
+
const activeOptions = {
|
|
1026
|
+
...options,
|
|
1027
|
+
modelSelection: activeModelSelection,
|
|
1028
|
+
};
|
|
1029
|
+
const createClient = clientFactory ?? createPxiChatClient;
|
|
1030
|
+
if (!activeSession && (sessionClient || !resolvedClient)) {
|
|
1031
|
+
const session = await serverSessionClient.createSession({
|
|
1032
|
+
temporary: isDraftTemporary,
|
|
1033
|
+
model: activeModelSelection,
|
|
1034
|
+
});
|
|
1035
|
+
setActiveSession(session);
|
|
1036
|
+
if (!resolvedClient) {
|
|
1037
|
+
resolvedClient = createClient({
|
|
1038
|
+
options: activeOptions,
|
|
1039
|
+
agentSessionId: session.id,
|
|
1040
|
+
});
|
|
1041
|
+
}
|
|
1042
|
+
}
|
|
1043
|
+
else if (!resolvedClient && activeSession) {
|
|
1044
|
+
resolvedClient = createClient({
|
|
1045
|
+
options: activeOptions,
|
|
1046
|
+
agentSessionId: activeSession.id,
|
|
1047
|
+
});
|
|
1048
|
+
}
|
|
1049
|
+
if (!resolvedClient) {
|
|
1050
|
+
throw new Error("Could not initialize the PXI chat client.");
|
|
1051
|
+
}
|
|
1052
|
+
return resolvedClient.sendMessage({
|
|
1053
|
+
messages: nextMessages,
|
|
1054
|
+
abortSignal: abortController.signal,
|
|
1055
|
+
onAssistantMessage: (assistantMessage) => {
|
|
1056
|
+
streamingAssistantMessageRef.current = assistantMessage;
|
|
1057
|
+
setMessages([...nextMessages, assistantMessage]);
|
|
1058
|
+
},
|
|
1059
|
+
onSessionTitle: (title) => {
|
|
1060
|
+
setActiveSession((currentSession) => currentSession ? { ...currentSession, title } : currentSession);
|
|
1061
|
+
},
|
|
1062
|
+
});
|
|
1063
|
+
})()
|
|
402
1064
|
.then((assistantMessage) => {
|
|
403
1065
|
if (abortController.signal.aborted) {
|
|
404
1066
|
return;
|
|
@@ -411,6 +1073,73 @@ export function PxiApp({ options, client, initialMessages = [] }) {
|
|
|
411
1073
|
if (abortController.signal.aborted) {
|
|
412
1074
|
return;
|
|
413
1075
|
}
|
|
1076
|
+
if (isSessionBusyError({ error: err })) {
|
|
1077
|
+
// Another client's turn holds the session lock (HTTP 409). Withdraw
|
|
1078
|
+
// the optimistic user message back into the composer (unless the
|
|
1079
|
+
// user already typed something new) and enter the busy state; the
|
|
1080
|
+
// poll refreshes the transcript once the other turn completes.
|
|
1081
|
+
setMessages((currentMessages) => currentMessages.filter((message) => message.id !== userMessage.id));
|
|
1082
|
+
setDraft((currentDraft) => currentDraft.value
|
|
1083
|
+
? currentDraft
|
|
1084
|
+
: { value: text, cursorIndex: text.length });
|
|
1085
|
+
setIsSessionBusy(true);
|
|
1086
|
+
return;
|
|
1087
|
+
}
|
|
1088
|
+
const isModelStale = isSessionModelStaleError({ error: err });
|
|
1089
|
+
if (isModelStale || isSessionMessagesStaleError({ error: err })) {
|
|
1090
|
+
// The send was rejected because this client's view of the session is
|
|
1091
|
+
// out of date (HTTP 409): either another client appended to the
|
|
1092
|
+
// transcript, or moved the session to a different model. Withdraw
|
|
1093
|
+
// the optimistic user message back into the composer and refetch the
|
|
1094
|
+
// session; if a turn is already running elsewhere, hand off to the
|
|
1095
|
+
// busy poll instead of the one-shot notice.
|
|
1096
|
+
setMessages((currentMessages) => currentMessages.filter((message) => message.id !== userMessage.id));
|
|
1097
|
+
setDraft((currentDraft) => currentDraft.value
|
|
1098
|
+
? currentDraft
|
|
1099
|
+
: { value: text, cursorIndex: text.length });
|
|
1100
|
+
const staleSessionId = activeSession?.id;
|
|
1101
|
+
if (!staleSessionId) {
|
|
1102
|
+
return;
|
|
1103
|
+
}
|
|
1104
|
+
const requestId = sessionRequestIdRef.current;
|
|
1105
|
+
void serverSessionClient
|
|
1106
|
+
.getSession({ sessionId: staleSessionId })
|
|
1107
|
+
.then(async (session) => {
|
|
1108
|
+
// A session switch or /new superseded this refresh.
|
|
1109
|
+
if (sessionRequestIdRef.current !== requestId)
|
|
1110
|
+
return;
|
|
1111
|
+
recordSyncedSessionState(session);
|
|
1112
|
+
const restoredModel = await resolveSessionModel(session.model);
|
|
1113
|
+
if (sessionRequestIdRef.current !== requestId)
|
|
1114
|
+
return;
|
|
1115
|
+
// A 409 racing the user's own /model write must not flip the
|
|
1116
|
+
// header back to the old model or announce the reverse of what
|
|
1117
|
+
// the user did: the in-flight write re-applies their pick and
|
|
1118
|
+
// the poll reconciles any residue.
|
|
1119
|
+
const hasModelWriteInFlight = modelWriteCountRef.current > 0;
|
|
1120
|
+
setActiveSession(session);
|
|
1121
|
+
if (!hasModelWriteInFlight) {
|
|
1122
|
+
setActiveModelSelection(restoredModel);
|
|
1123
|
+
}
|
|
1124
|
+
setMessages(session.messages);
|
|
1125
|
+
if (isModelStale) {
|
|
1126
|
+
if (!hasModelWriteInFlight) {
|
|
1127
|
+
setShowModelStaleNotice(true);
|
|
1128
|
+
}
|
|
1129
|
+
}
|
|
1130
|
+
else {
|
|
1131
|
+
setShowStaleRefreshNotice(true);
|
|
1132
|
+
}
|
|
1133
|
+
if (session.isActive === true) {
|
|
1134
|
+
setIsSessionBusy(true);
|
|
1135
|
+
}
|
|
1136
|
+
})
|
|
1137
|
+
.catch(() => {
|
|
1138
|
+
// Refresh failed; the draft is intact and the next send will
|
|
1139
|
+
// hit the stale rejection again.
|
|
1140
|
+
});
|
|
1141
|
+
return;
|
|
1142
|
+
}
|
|
414
1143
|
setError(err instanceof Error ? err.message : String(err));
|
|
415
1144
|
})
|
|
416
1145
|
.finally(() => {
|
|
@@ -430,10 +1159,32 @@ export function PxiApp({ options, client, initialMessages = [] }) {
|
|
|
430
1159
|
return;
|
|
431
1160
|
}
|
|
432
1161
|
if (isBackspaceInput({ input })) {
|
|
1162
|
+
if (modelPicker) {
|
|
1163
|
+
setModelPicker((current) => current
|
|
1164
|
+
? {
|
|
1165
|
+
...current,
|
|
1166
|
+
query: current.query.slice(0, -1),
|
|
1167
|
+
selectedIndex: 0,
|
|
1168
|
+
}
|
|
1169
|
+
: null);
|
|
1170
|
+
return;
|
|
1171
|
+
}
|
|
1172
|
+
if (sessionPicker) {
|
|
1173
|
+
setSessionPicker((current) => current
|
|
1174
|
+
? {
|
|
1175
|
+
...current,
|
|
1176
|
+
query: current.query.slice(0, -1),
|
|
1177
|
+
selectedIndex: 0,
|
|
1178
|
+
}
|
|
1179
|
+
: null);
|
|
1180
|
+
return;
|
|
1181
|
+
}
|
|
433
1182
|
setDraft((value) => deleteDraftTextBeforeCursor({ draft: value }));
|
|
434
1183
|
return;
|
|
435
1184
|
}
|
|
436
1185
|
if (isForwardDeleteInput({ input })) {
|
|
1186
|
+
if (modelPicker || sessionPicker)
|
|
1187
|
+
return;
|
|
437
1188
|
setDraft((value) => deleteDraftTextAtCursor({ draft: value }));
|
|
438
1189
|
}
|
|
439
1190
|
};
|
|
@@ -449,20 +1200,149 @@ export function PxiApp({ options, client, initialMessages = [] }) {
|
|
|
449
1200
|
};
|
|
450
1201
|
}, [inputEventEmitter]);
|
|
451
1202
|
useInput((input, key) => {
|
|
452
|
-
if (key.escape) {
|
|
453
|
-
interruptStream();
|
|
454
|
-
return;
|
|
455
|
-
}
|
|
456
1203
|
if ((key.ctrl && input === "c") || (key.ctrl && input === "d")) {
|
|
457
1204
|
handleExit();
|
|
458
1205
|
return;
|
|
459
1206
|
}
|
|
1207
|
+
if (modelPicker) {
|
|
1208
|
+
if (key.escape) {
|
|
1209
|
+
closeModelPicker();
|
|
1210
|
+
return;
|
|
1211
|
+
}
|
|
1212
|
+
if (modelPicker.status !== "ready") {
|
|
1213
|
+
return;
|
|
1214
|
+
}
|
|
1215
|
+
const filteredModels = getFilteredModels({
|
|
1216
|
+
models: modelPicker.models,
|
|
1217
|
+
query: modelPicker.query,
|
|
1218
|
+
});
|
|
1219
|
+
if (key.upArrow) {
|
|
1220
|
+
setModelPicker((current) => current
|
|
1221
|
+
? {
|
|
1222
|
+
...current,
|
|
1223
|
+
selectedIndex: Math.max(0, current.selectedIndex - 1),
|
|
1224
|
+
}
|
|
1225
|
+
: null);
|
|
1226
|
+
return;
|
|
1227
|
+
}
|
|
1228
|
+
if (key.downArrow) {
|
|
1229
|
+
setModelPicker((current) => current
|
|
1230
|
+
? {
|
|
1231
|
+
...current,
|
|
1232
|
+
selectedIndex: Math.min(Math.max(filteredModels.length - 1, 0), current.selectedIndex + 1),
|
|
1233
|
+
}
|
|
1234
|
+
: null);
|
|
1235
|
+
return;
|
|
1236
|
+
}
|
|
1237
|
+
if (key.return) {
|
|
1238
|
+
selectModel();
|
|
1239
|
+
return;
|
|
1240
|
+
}
|
|
1241
|
+
if (key.backspace ||
|
|
1242
|
+
key.delete ||
|
|
1243
|
+
isStrippedBracketedPasteMarkerInput({ input }) ||
|
|
1244
|
+
isKeyboardProtocolResponseInput({ input })) {
|
|
1245
|
+
return;
|
|
1246
|
+
}
|
|
1247
|
+
if (input) {
|
|
1248
|
+
const text = getDraftInputText({ input }).replace(/\n/g, "");
|
|
1249
|
+
if (text) {
|
|
1250
|
+
setModelPicker((current) => current
|
|
1251
|
+
? {
|
|
1252
|
+
...current,
|
|
1253
|
+
query: current.query + text,
|
|
1254
|
+
selectedIndex: 0,
|
|
1255
|
+
error: null,
|
|
1256
|
+
status: "ready",
|
|
1257
|
+
}
|
|
1258
|
+
: null);
|
|
1259
|
+
}
|
|
1260
|
+
}
|
|
1261
|
+
return;
|
|
1262
|
+
}
|
|
1263
|
+
if (sessionPicker) {
|
|
1264
|
+
if (key.escape) {
|
|
1265
|
+
closeSessionPicker();
|
|
1266
|
+
return;
|
|
1267
|
+
}
|
|
1268
|
+
if (sessionPicker.status === "loading" ||
|
|
1269
|
+
sessionPicker.status === "restoring") {
|
|
1270
|
+
return;
|
|
1271
|
+
}
|
|
1272
|
+
const filteredSessions = getFilteredSessions({
|
|
1273
|
+
sessions: sessionPicker.sessions,
|
|
1274
|
+
query: sessionPicker.query,
|
|
1275
|
+
});
|
|
1276
|
+
if (key.upArrow) {
|
|
1277
|
+
setSessionPicker((current) => current
|
|
1278
|
+
? {
|
|
1279
|
+
...current,
|
|
1280
|
+
selectedIndex: Math.max(0, current.selectedIndex - 1),
|
|
1281
|
+
}
|
|
1282
|
+
: null);
|
|
1283
|
+
return;
|
|
1284
|
+
}
|
|
1285
|
+
if (key.downArrow) {
|
|
1286
|
+
setSessionPicker((current) => current
|
|
1287
|
+
? {
|
|
1288
|
+
...current,
|
|
1289
|
+
selectedIndex: Math.min(Math.max(filteredSessions.length - 1, 0), current.selectedIndex + 1),
|
|
1290
|
+
}
|
|
1291
|
+
: null);
|
|
1292
|
+
return;
|
|
1293
|
+
}
|
|
1294
|
+
if (key.return) {
|
|
1295
|
+
restoreSelectedSession();
|
|
1296
|
+
return;
|
|
1297
|
+
}
|
|
1298
|
+
if (key.backspace ||
|
|
1299
|
+
key.delete ||
|
|
1300
|
+
isStrippedBracketedPasteMarkerInput({ input }) ||
|
|
1301
|
+
isKeyboardProtocolResponseInput({ input })) {
|
|
1302
|
+
return;
|
|
1303
|
+
}
|
|
1304
|
+
if (input) {
|
|
1305
|
+
const text = getDraftInputText({ input }).replace(/\n/g, "");
|
|
1306
|
+
if (text) {
|
|
1307
|
+
setSessionPicker((current) => current
|
|
1308
|
+
? {
|
|
1309
|
+
...current,
|
|
1310
|
+
query: current.query + text,
|
|
1311
|
+
selectedIndex: 0,
|
|
1312
|
+
error: null,
|
|
1313
|
+
status: "ready",
|
|
1314
|
+
}
|
|
1315
|
+
: null);
|
|
1316
|
+
}
|
|
1317
|
+
}
|
|
1318
|
+
return;
|
|
1319
|
+
}
|
|
1320
|
+
if (key.escape) {
|
|
1321
|
+
interruptStream();
|
|
1322
|
+
return;
|
|
1323
|
+
}
|
|
460
1324
|
if (status === "streaming") {
|
|
461
1325
|
return;
|
|
462
1326
|
}
|
|
463
1327
|
if (isKeyboardProtocolResponseInput({ input })) {
|
|
464
1328
|
return;
|
|
465
1329
|
}
|
|
1330
|
+
if (key.tab) {
|
|
1331
|
+
const commandName = getSlashCommandName(draft.value);
|
|
1332
|
+
const suggestion = commandName !== null &&
|
|
1333
|
+
draft.value.length > 1 &&
|
|
1334
|
+
!draft.value.includes(" ")
|
|
1335
|
+
? matchingCommands(commandName)[0]
|
|
1336
|
+
: undefined;
|
|
1337
|
+
if (suggestion) {
|
|
1338
|
+
const completedCommand = `/${suggestion.name}`;
|
|
1339
|
+
setDraft({
|
|
1340
|
+
value: completedCommand,
|
|
1341
|
+
cursorIndex: completedCommand.length,
|
|
1342
|
+
});
|
|
1343
|
+
}
|
|
1344
|
+
return;
|
|
1345
|
+
}
|
|
466
1346
|
if (key.ctrl && input === "a") {
|
|
467
1347
|
setDraft((value) => moveDraftCursorToStart({ draft: value }));
|
|
468
1348
|
return;
|
|
@@ -518,6 +1398,23 @@ export function PxiApp({ options, client, initialMessages = [] }) {
|
|
|
518
1398
|
}
|
|
519
1399
|
}
|
|
520
1400
|
});
|
|
521
|
-
|
|
1401
|
+
// The one-shot yellow notices share a single line; precedence mirrors the
|
|
1402
|
+
// web app's: compaction result, then model moved elsewhere, then transcript
|
|
1403
|
+
// refreshed, then the persisted-model validation warning.
|
|
1404
|
+
const noticeText = compactionNotice ??
|
|
1405
|
+
(showModelStaleNotice
|
|
1406
|
+
? getSessionModelStaleStatusText({
|
|
1407
|
+
modelSelection: activeModelSelection,
|
|
1408
|
+
})
|
|
1409
|
+
: showStaleRefreshNotice
|
|
1410
|
+
? SESSION_STALE_STATUS_TEXT
|
|
1411
|
+
: modelValidationWarning);
|
|
1412
|
+
return (_jsxs(Box, { flexDirection: "column", paddingX: 1, children: [_jsx(PxiBanner, {}), _jsxs(Text, { dimColor: true, children: ["endpoint: ", options.config.endpoint, " | model:", " ", getModelLabel({ modelSelection: activeModelSelection }), " | session:", " ", activeSession
|
|
1413
|
+
? getSessionTitle({ title: activeSession.title })
|
|
1414
|
+
: isDraftTemporary
|
|
1415
|
+
? "new temporary session"
|
|
1416
|
+
: "new session"] }), _jsx(Box, { marginTop: 1, flexDirection: "column", children: _jsx(Transcript, { messages: messages, liveMessageId: status === "streaming" || isSessionBusy
|
|
1417
|
+
? (messages.at(-1)?.id ?? null)
|
|
1418
|
+
: null, phoenixBaseUrl: options.config.endpoint }) }), error ? (_jsx(Box, { marginTop: 1, children: _jsxs(Text, { color: "red", children: ["Error: ", error] }) })) : null, status === "streaming" ? (_jsx(ThinkingIndicator, {})) : isCompacting ? (_jsx(StatusSpinnerLine, { text: COMPACTING_STATUS_TEXT })) : isSessionBusy ? (_jsx(StatusSpinnerLine, { text: SESSION_BUSY_STATUS_TEXT })) : noticeText ? (_jsx(Text, { color: "yellow", children: noticeText })) : null, modelPicker ? (_jsx(ModelPicker, { state: modelPicker })) : sessionPicker ? (_jsx(SessionPicker, { state: sessionPicker })) : (_jsx(InputPrompt, { draft: draft, status: status, usageLine: formatTokenUsageLine(getLatestAssistantUsage(messages)), modelLabel: activeModelSelection.modelName }))] }));
|
|
522
1419
|
}
|
|
523
1420
|
//# sourceMappingURL=App.js.map
|