@arizeai/phoenix-cli 1.15.2 → 1.16.0

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