@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.
Files changed (40) hide show
  1. package/README.md +2 -1
  2. package/build/pxi/App.d.ts +9 -2
  3. package/build/pxi/App.d.ts.map +1 -1
  4. package/build/pxi/App.js +935 -35
  5. package/build/pxi/App.js.map +1 -1
  6. package/build/pxi/client.d.ts +36 -21
  7. package/build/pxi/client.d.ts.map +1 -1
  8. package/build/pxi/client.js +373 -36
  9. package/build/pxi/client.js.map +1 -1
  10. package/build/pxi/commands.d.ts +10 -1
  11. package/build/pxi/commands.d.ts.map +1 -1
  12. package/build/pxi/commands.js +27 -2
  13. package/build/pxi/commands.js.map +1 -1
  14. package/build/pxi/compaction.d.ts +19 -0
  15. package/build/pxi/compaction.d.ts.map +1 -0
  16. package/build/pxi/compaction.js +24 -0
  17. package/build/pxi/compaction.js.map +1 -0
  18. package/build/pxi/index.d.ts +5 -5
  19. package/build/pxi/index.d.ts.map +1 -1
  20. package/build/pxi/index.js +7 -6
  21. package/build/pxi/index.js.map +1 -1
  22. package/build/pxi/options.d.ts +1 -1
  23. package/build/pxi/options.d.ts.map +1 -1
  24. package/build/pxi/options.js +5 -1
  25. package/build/pxi/options.js.map +1 -1
  26. package/build/pxi/preflight.d.ts +51 -4
  27. package/build/pxi/preflight.d.ts.map +1 -1
  28. package/build/pxi/preflight.js +100 -4
  29. package/build/pxi/preflight.js.map +1 -1
  30. package/build/pxi/tokenUsage.d.ts +3 -3
  31. package/build/pxi/tokenUsage.d.ts.map +1 -1
  32. package/build/pxi/tokenUsage.js +2 -1
  33. package/build/pxi/tokenUsage.js.map +1 -1
  34. package/build/pxi/toolPresentation.d.ts +1 -1
  35. package/build/pxi/toolPresentation.d.ts.map +1 -1
  36. package/build/pxi/toolPresentation.js +4 -1
  37. package/build/pxi/toolPresentation.js.map +1 -1
  38. package/build/pxi/types.d.ts +116 -58
  39. package/build/pxi/types.d.ts.map +1 -1
  40. 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 = /^\x1B\[(?:8|127)(?:;\d+(?::[12])?(?:;[\d:]+)?)?u$/;
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 the active model for the status line (e.g. `ANTHROPIC/claude-opus-5`). */
71
- function getModelLabel(options) {
72
- if (options.modelSelection.providerType === "custom") {
73
- return `custom:${options.modelSelection.providerId}/${options.modelSelection.modelName}`;
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 `${options.modelSelection.provider}/${options.modelSelection.modelName}`;
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 chatClient = useMemo(() => client ?? createPxiChatClient({ options }), [client, options]);
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 clearMessages = () => {
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
- clearMessages,
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 = [...messages, userMessage];
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 chatClient
394
- .sendMessage({
395
- messages: nextMessages,
396
- abortSignal: abortController.signal,
397
- onAssistantMessage: (assistantMessage) => {
398
- streamingAssistantMessageRef.current = assistantMessage;
399
- setMessages([...nextMessages, assistantMessage]);
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
- return (_jsxs(Box, { flexDirection: "column", paddingX: 1, children: [_jsx(PxiBanner, {}), _jsxs(Text, { dimColor: true, children: ["endpoint: ", options.config.endpoint, " | model: ", getModelLabel(options), " | session: ", options.sessionId] }), _jsx(Box, { marginTop: 1, flexDirection: "column", children: _jsx(Transcript, { messages: messages, phoenixBaseUrl: options.config.endpoint }) }), error ? (_jsx(Box, { marginTop: 1, children: _jsxs(Text, { color: "red", children: ["Error: ", error] }) })) : null, status === "streaming" ? _jsx(ThinkingIndicator, {}) : null, _jsx(InputPrompt, { draft: draft, status: status, usageLine: formatTokenUsageLine(getLatestAssistantUsage(messages)), modelLabel: options.modelSelection.modelName })] }));
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