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