wave-code 1.0.1 → 1.0.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/components/ChatInterface.js +7 -5
- package/dist/components/InputBox.d.ts +1 -0
- package/dist/components/InputBox.js +3 -2
- package/dist/components/LoginCommand.js +4 -2
- package/dist/components/MessageList.d.ts +1 -2
- package/dist/components/MessageList.js +2 -2
- package/dist/components/Notifications.d.ts +7 -0
- package/dist/components/Notifications.js +9 -0
- package/dist/components/StatusLine.d.ts +0 -2
- package/dist/components/StatusLine.js +6 -10
- package/dist/contexts/useChat.d.ts +1 -1
- package/dist/contexts/useChat.js +14 -31
- package/dist/stdio/agentBridge.js +8 -0
- package/package.json +2 -2
- package/src/components/ChatInterface.tsx +7 -5
- package/src/components/InputBox.tsx +15 -6
- package/src/components/LoginCommand.tsx +4 -2
- package/src/components/MessageList.tsx +0 -6
- package/src/components/Notifications.tsx +31 -0
- package/src/components/StatusLine.tsx +17 -34
- package/src/contexts/useChat.tsx +18 -47
- package/src/stdio/agentBridge.ts +8 -0
|
@@ -11,7 +11,7 @@ import { ConfirmationDetails } from "./ConfirmationDetails.js";
|
|
|
11
11
|
import { ConfirmationSelector } from "./ConfirmationSelector.js";
|
|
12
12
|
import { useChat } from "../contexts/useChat.js";
|
|
13
13
|
export const ChatInterface = () => {
|
|
14
|
-
const { messages, isLoading, isCommandRunning, isCompacting, sendMessage, abortMessage, mcpServers, connectMcpServer, disconnectMcpServer, isExpanded, sessionId, latestTotalTokens, maxInputTokens, slashCommands, hasSlashCommand, isConfirmationVisible, hasPendingConfirmations, confirmingTool, handleConfirmationDecision, handleConfirmationCancel, version, workdir, remountKey,
|
|
14
|
+
const { messages, isLoading, isCommandRunning, isCompacting, sendMessage, abortMessage, mcpServers, connectMcpServer, disconnectMcpServer, isExpanded, sessionId, latestTotalTokens, maxInputTokens, slashCommands, hasSlashCommand, isConfirmationVisible, hasPendingConfirmations, confirmingTool, handleConfirmationDecision, handleConfirmationCancel, version, workdir, remountKey, forceRemount, getGatewayConfig, } = useChat();
|
|
15
15
|
const displayMessages = messages;
|
|
16
16
|
const [forceStatic, setForceStatic] = useState(false);
|
|
17
17
|
const { stdout } = useStdout();
|
|
@@ -19,8 +19,10 @@ export const ChatInterface = () => {
|
|
|
19
19
|
const chatInterfaceRef = useRef(null);
|
|
20
20
|
// Compute whether the user has any usable auth/direct-API config,
|
|
21
21
|
// so the welcome page can prompt /login when neither is present.
|
|
22
|
+
// An SSO token counts as authenticated even if the access token is stale —
|
|
23
|
+
// it refreshes lazily on the next API call (matching the claude-code CLI).
|
|
22
24
|
const computeAuthState = useCallback(() => {
|
|
23
|
-
if (authService.
|
|
25
|
+
if (authService.getSSOToken())
|
|
24
26
|
return true;
|
|
25
27
|
const gateway = getGatewayConfig();
|
|
26
28
|
return Boolean(gateway.apiKey || gateway.baseURL);
|
|
@@ -44,16 +46,16 @@ export const ChatInterface = () => {
|
|
|
44
46
|
}
|
|
45
47
|
else if (forceStatic && !hasPendingConfirmations) {
|
|
46
48
|
setForceStatic(false);
|
|
47
|
-
|
|
49
|
+
forceRemount();
|
|
48
50
|
}
|
|
49
51
|
}, [
|
|
50
52
|
isConfirmationVisible,
|
|
51
53
|
terminalHeight,
|
|
52
54
|
forceStatic,
|
|
53
55
|
hasPendingConfirmations,
|
|
54
|
-
|
|
56
|
+
forceRemount,
|
|
55
57
|
]);
|
|
56
58
|
if (!sessionId)
|
|
57
59
|
return null;
|
|
58
|
-
return (_jsxs(Box, { ref: chatInterfaceRef, flexDirection: "column", children: [_jsx(MessageList, { messages: displayMessages, isExpanded: isExpanded, version: version, workdir: workdir, forceStatic: forceStatic
|
|
60
|
+
return (_jsxs(Box, { ref: chatInterfaceRef, flexDirection: "column", children: [_jsx(MessageList, { messages: displayMessages, isExpanded: isExpanded, version: version, workdir: workdir, forceStatic: forceStatic }, remountKey), !isConfirmationVisible && !isExpanded && (_jsxs(_Fragment, { children: [(isLoading || isCommandRunning || isCompacting) && (_jsx(LoadingIndicator, { isLoading: isLoading, isCommandRunning: isCommandRunning, isCompacting: isCompacting, latestTotalTokens: latestTotalTokens })), _jsx(TaskList, {}), _jsx(QueuedMessageList, {}), _jsx(InputBox, { isLoading: isLoading, isCommandRunning: isCommandRunning, isCompacting: isCompacting, sendMessage: sendMessage, abortMessage: abortMessage, mcpServers: mcpServers, connectMcpServer: connectMcpServer, disconnectMcpServer: disconnectMcpServer, slashCommands: slashCommands, hasSlashCommand: hasSlashCommand, latestTotalTokens: latestTotalTokens, maxInputTokens: maxInputTokens, showLoginHint: showLoginHint })] })), isConfirmationVisible && (_jsxs(_Fragment, { children: [forceStatic ? (_jsx(Static, { items: [{ key: "confirmation-details" }], children: () => (_jsx(ConfirmationDetails, { toolName: confirmingTool.name, toolInput: confirmingTool.input, planContent: confirmingTool.planContent, isExpanded: isExpanded }, "confirmation-details")) })) : (_jsx(ConfirmationDetails, { toolName: confirmingTool.name, toolInput: confirmingTool.input, planContent: confirmingTool.planContent, isExpanded: isExpanded })), _jsx(ConfirmationSelector, { toolName: confirmingTool.name, toolInput: confirmingTool.input, suggestedPrefix: confirmingTool.suggestedPrefix, hidePersistentOption: confirmingTool.hidePersistentOption, isExpanded: isExpanded, onDecision: handleConfirmationDecision, onCancel: handleConfirmationCancel })] }))] }));
|
|
59
61
|
};
|
|
@@ -15,12 +15,13 @@ import { PluginManagerShell } from "./PluginManagerShell.js";
|
|
|
15
15
|
import { ModelSelector } from "./ModelSelector.js";
|
|
16
16
|
import { WorkflowManager } from "./WorkflowManager.js";
|
|
17
17
|
import { StatusLine } from "./StatusLine.js";
|
|
18
|
+
import { Notifications } from "./Notifications.js";
|
|
18
19
|
import { BtwDisplay } from "./BtwDisplay.js";
|
|
19
20
|
import { useInputManager } from "../hooks/useInputManager.js";
|
|
20
21
|
import { useChat } from "../contexts/useChat.js";
|
|
21
22
|
export const INPUT_PLACEHOLDER_TEXT = "Type your message (use /help for more info)...";
|
|
22
23
|
export const INPUT_PLACEHOLDER_TEXT_PREFIX = INPUT_PLACEHOLDER_TEXT.substring(0, 10);
|
|
23
|
-
export const InputBox = ({ isLoading, isCommandRunning, isCompacting, sendMessage = () => { }, abortMessage = () => { }, mcpServers = [], connectMcpServer = async () => false, disconnectMcpServer = async () => false, slashCommands = [], hasSlashCommand = () => false, latestTotalTokens = 0, maxInputTokens = 200000, }) => {
|
|
24
|
+
export const InputBox = ({ isLoading, isCommandRunning, isCompacting, sendMessage = () => { }, abortMessage = () => { }, mcpServers = [], connectMcpServer = async () => false, disconnectMcpServer = async () => false, slashCommands = [], hasSlashCommand = () => false, latestTotalTokens = 0, maxInputTokens = 200000, showLoginHint = false, }) => {
|
|
24
25
|
const { permissionMode: chatPermissionMode, setPermissionMode: setChatPermissionMode, handleRewindSelect, backgroundCurrentTask, messages, getFullMessageThread, sessionId, workingDirectory, askBtw, clearMessages, compact, currentModel, configuredModels, setModel, recreateAgent, recallQueuedMessage, queuedMessages, setIsBtwActive, } = useChat();
|
|
25
26
|
// Ref to hold setInputText so queue callbacks can access it before useInputManager returns
|
|
26
27
|
const setInputTextRef = useRef(() => { });
|
|
@@ -157,5 +158,5 @@ export const InputBox = ({ isLoading, isCommandRunning, isCompacting, sendMessag
|
|
|
157
158
|
showStatusCommand ||
|
|
158
159
|
showLoginCommand ||
|
|
159
160
|
showPluginManager ||
|
|
160
|
-
showWorkflowManager || (_jsxs(Box, { flexDirection: "column", children: [escClearPending && _jsx(Text, { color: "gray", children: "\u518D\u6B21\u6309 Esc \u6E05\u7A7A\u8F93\u5165" }), _jsx(Box, { borderStyle: "single", borderColor: "gray", borderLeft: false, borderRight: false, children: _jsx(Text, { color: isPlaceholder ? "gray" : "white", children: shouldShowCursor ? (_jsxs(_Fragment, { children: [beforeCursor, _jsx(Text, { backgroundColor: "white", color: "black", children: atCursor }), afterCursor] })) : (displayText) }) }), _jsx(StatusLine, { permissionMode: permissionMode, isShellCommand: isShellCommand, latestTotalTokens: latestTotalTokens, maxInputTokens: maxInputTokens })] }))] }));
|
|
161
|
+
showWorkflowManager || (_jsxs(Box, { flexDirection: "column", children: [escClearPending && _jsx(Text, { color: "gray", children: "\u518D\u6B21\u6309 Esc \u6E05\u7A7A\u8F93\u5165" }), _jsx(Box, { borderStyle: "single", borderColor: "gray", borderLeft: false, borderRight: false, children: _jsx(Text, { color: isPlaceholder ? "gray" : "white", children: shouldShowCursor ? (_jsxs(_Fragment, { children: [beforeCursor, _jsx(Text, { backgroundColor: "white", color: "black", children: atCursor }), afterCursor] })) : (displayText) }) }), _jsxs(Box, { justifyContent: "space-between", children: [_jsx(StatusLine, { permissionMode: permissionMode, isShellCommand: isShellCommand }), _jsx(Notifications, { latestTotalTokens: latestTotalTokens, maxInputTokens: maxInputTokens, showLoginHint: showLoginHint })] })] }))] }));
|
|
161
162
|
};
|
|
@@ -72,7 +72,9 @@ export const LoginCommand = ({ onCancel }) => {
|
|
|
72
72
|
const handleEnter = async () => {
|
|
73
73
|
if (isLoadingRef.current)
|
|
74
74
|
return;
|
|
75
|
-
|
|
75
|
+
// A stale-but-present SSO token still counts as logged in; it refreshes
|
|
76
|
+
// lazily on the next API call. Enter toggles logout only when a token exists.
|
|
77
|
+
const isAuthenticated = Boolean(authService.getSSOToken());
|
|
76
78
|
if (isAuthenticated) {
|
|
77
79
|
await authService.clearAuth();
|
|
78
80
|
setMessage("Logged out successfully");
|
|
@@ -112,7 +114,7 @@ export const LoginCommand = ({ onCancel }) => {
|
|
|
112
114
|
setIsLoading(false);
|
|
113
115
|
}
|
|
114
116
|
};
|
|
115
|
-
const isAuthenticated = authService.
|
|
117
|
+
const isAuthenticated = Boolean(authService.getSSOToken());
|
|
116
118
|
const token = authService.getSSOToken();
|
|
117
119
|
const serverUrl = authService.getServerUrl();
|
|
118
120
|
const truncatedToken = token && token.length > 14
|
|
@@ -6,6 +6,5 @@ export interface MessageListProps {
|
|
|
6
6
|
forceStatic?: boolean;
|
|
7
7
|
version?: string;
|
|
8
8
|
workdir?: string;
|
|
9
|
-
showLoginHint?: boolean;
|
|
10
9
|
}
|
|
11
|
-
export declare const MessageList: React.MemoExoticComponent<({ messages, isExpanded, forceStatic, version, workdir,
|
|
10
|
+
export declare const MessageList: React.MemoExoticComponent<({ messages, isExpanded, forceStatic, version, workdir, }: MessageListProps) => import("react/jsx-runtime").JSX.Element>;
|
|
@@ -5,7 +5,7 @@ import { Box, Text, Static } from "ink";
|
|
|
5
5
|
import { MessageBlockItem } from "./MessageBlockItem.js";
|
|
6
6
|
const MAX_MESSAGES_COLLAPSED = 30;
|
|
7
7
|
const MAX_MESSAGES_EXPANDED = 10;
|
|
8
|
-
export const MessageList = React.memo(({ messages, isExpanded = false, forceStatic = false, version, workdir,
|
|
8
|
+
export const MessageList = React.memo(({ messages, isExpanded = false, forceStatic = false, version, workdir, }) => {
|
|
9
9
|
const maxMessages = isExpanded
|
|
10
10
|
? MAX_MESSAGES_EXPANDED
|
|
11
11
|
: MAX_MESSAGES_COLLAPSED;
|
|
@@ -74,7 +74,7 @@ export const MessageList = React.memo(({ messages, isExpanded = false, forceStat
|
|
|
74
74
|
return null;
|
|
75
75
|
}
|
|
76
76
|
return (_jsx(MessageBlockItem, { block: item.block, message: item.message, isExpanded: isExpanded, paddingTop: 1 }, item.key));
|
|
77
|
-
} })),
|
|
77
|
+
} })), dynamicBlocks.length > 0 && (_jsx(Box, { flexDirection: "column", children: dynamicBlocks.map((item) => (_jsx(MessageBlockItem, { block: item.block, message: item.message, isExpanded: isExpanded, paddingTop: 1 }, item.key))) }))] }));
|
|
78
78
|
});
|
|
79
79
|
// Add display name for debugging
|
|
80
80
|
MessageList.displayName = "MessageList";
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
+
import { Box, Text } from "ink";
|
|
3
|
+
export const Notifications = ({ latestTotalTokens = 0, maxInputTokens = 200000, showLoginHint = false, }) => {
|
|
4
|
+
const percentage = latestTotalTokens > 0
|
|
5
|
+
? Math.min(Math.round((latestTotalTokens / maxInputTokens) * 100), 100)
|
|
6
|
+
: 0;
|
|
7
|
+
const contextColor = percentage > 95 ? "red" : percentage > 80 ? "yellow" : "gray";
|
|
8
|
+
return (_jsxs(Box, { gap: 1, children: [showLoginHint && _jsx(Text, { color: "gray", children: "Type /login to authenticate" }), percentage > 0 && (_jsxs(Text, { color: contextColor, children: [percentage, "% context"] }))] }));
|
|
9
|
+
};
|
|
@@ -1,13 +1,9 @@
|
|
|
1
1
|
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
2
|
import { Box, Text } from "ink";
|
|
3
|
-
export const StatusLine = ({ permissionMode, isShellCommand,
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
? "yellow"
|
|
10
|
-
: permissionMode === "bypassPermissions"
|
|
11
|
-
? "red"
|
|
12
|
-
: "cyan", bold: permissionMode === "bypassPermissions", children: permissionMode }), " ", "(Shift+Tab to cycle)"] }) })), percentage > 0 && (_jsxs(Text, { color: contextColor, children: [percentage, "% context"] }))] }));
|
|
3
|
+
export const StatusLine = ({ permissionMode, isShellCommand, }) => {
|
|
4
|
+
return (_jsx(Box, { children: isShellCommand ? (_jsxs(Text, { color: "gray", children: ["Shell: ", _jsx(Text, { color: "yellow", children: "Run shell command" })] })) : (_jsxs(Text, { color: "gray", children: ["Mode:", " ", _jsx(Text, { color: permissionMode === "plan"
|
|
5
|
+
? "yellow"
|
|
6
|
+
: permissionMode === "bypassPermissions"
|
|
7
|
+
? "red"
|
|
8
|
+
: "cyan", bold: permissionMode === "bypassPermissions", children: permissionMode }), " ", "(Shift+Tab to cycle)"] })) }));
|
|
13
9
|
};
|
|
@@ -62,7 +62,7 @@ export interface ChatContextType {
|
|
|
62
62
|
handleConfirmationCancel: () => void;
|
|
63
63
|
backgroundCurrentTask: () => void;
|
|
64
64
|
remountKey: number;
|
|
65
|
-
|
|
65
|
+
forceRemount: () => void;
|
|
66
66
|
handleRewindSelect: (index: number) => Promise<void>;
|
|
67
67
|
getFullMessageThread: () => Promise<{
|
|
68
68
|
messages: Message[];
|
package/dist/contexts/useChat.js
CHANGED
|
@@ -147,29 +147,14 @@ export const ChatProvider = ({ children, bypassPermissions, permissionMode: init
|
|
|
147
147
|
const [currentConfirmation, setCurrentConfirmation] = useState(null);
|
|
148
148
|
// Remount state
|
|
149
149
|
const [remountKey, setRemountKey] = useState(0);
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
150
|
+
// Full terminal clear + remount so Ink's append-only <Static> re-renders.
|
|
151
|
+
// Used on structural actions (/clear, /compact, rewind, ctrl-o, forceStatic
|
|
152
|
+
// exit) where stale Static output must not linger on screen.
|
|
153
|
+
const forceRemount = useCallback(() => {
|
|
153
154
|
stdout?.write("\u001b[2J\u001b[3J\u001b[0;0H", () => {
|
|
154
155
|
setRemountKey((prev) => prev + 1);
|
|
155
156
|
});
|
|
156
|
-
},
|
|
157
|
-
useEffect(() => {
|
|
158
|
-
return () => {
|
|
159
|
-
requestRemount.cancel();
|
|
160
|
-
};
|
|
161
|
-
}, [requestRemount]);
|
|
162
|
-
// Track sessionId changes to trigger remount
|
|
163
|
-
useEffect(() => {
|
|
164
|
-
if (prevSessionId.current &&
|
|
165
|
-
sessionId &&
|
|
166
|
-
prevSessionId.current !== sessionId) {
|
|
167
|
-
requestRemount();
|
|
168
|
-
}
|
|
169
|
-
if (sessionId) {
|
|
170
|
-
prevSessionId.current = sessionId;
|
|
171
|
-
}
|
|
172
|
-
}, [sessionId, requestRemount]);
|
|
157
|
+
}, [stdout]);
|
|
173
158
|
// Status metadata state
|
|
174
159
|
const [workingDirectory, setWorkingDirectory] = useState("");
|
|
175
160
|
const agentRef = useRef(null);
|
|
@@ -553,11 +538,13 @@ export const ChatProvider = ({ children, bypassPermissions, permissionMode: init
|
|
|
553
538
|
const clearMessages = useCallback(async () => {
|
|
554
539
|
await agentRef.current?.clearMessages();
|
|
555
540
|
refreshMessages();
|
|
556
|
-
|
|
541
|
+
forceRemount();
|
|
542
|
+
}, [refreshMessages, forceRemount]);
|
|
557
543
|
const compact = useCallback(async (instructions) => {
|
|
558
544
|
await agentRef.current?.compact(instructions);
|
|
559
545
|
refreshMessages();
|
|
560
|
-
|
|
546
|
+
forceRemount();
|
|
547
|
+
}, [refreshMessages, forceRemount]);
|
|
561
548
|
// Unified interrupt method, interrupt both AI messages and command execution
|
|
562
549
|
const abortMessage = useCallback(() => {
|
|
563
550
|
agentRef.current?.abortMessage();
|
|
@@ -651,13 +638,13 @@ export const ChatProvider = ({ children, bypassPermissions, permissionMode: init
|
|
|
651
638
|
try {
|
|
652
639
|
await agentRef.current.truncateHistory(index);
|
|
653
640
|
refreshMessages();
|
|
654
|
-
|
|
641
|
+
forceRemount();
|
|
655
642
|
}
|
|
656
643
|
catch (error) {
|
|
657
644
|
logger.error("Failed to rewind:", error);
|
|
658
645
|
}
|
|
659
646
|
}
|
|
660
|
-
}, [
|
|
647
|
+
}, [forceRemount, refreshMessages]);
|
|
661
648
|
const getFullMessageThread = useCallback(async () => {
|
|
662
649
|
if (agentRef.current) {
|
|
663
650
|
return await agentRef.current.getFullMessageThread();
|
|
@@ -703,12 +690,8 @@ export const ChatProvider = ({ children, bypassPermissions, permissionMode: init
|
|
|
703
690
|
// Transitioning to COLLAPSED: Restore from agent's actual state
|
|
704
691
|
refreshMessages();
|
|
705
692
|
}
|
|
706
|
-
// Force remount
|
|
707
|
-
|
|
708
|
-
// a previous remount, leaving the UI stuck without a visual update
|
|
709
|
-
stdout?.write("\u001b[2J\u001b[3J\u001b[0;0H", () => {
|
|
710
|
-
setRemountKey((prev) => prev + 1);
|
|
711
|
-
});
|
|
693
|
+
// Force remount to ensure Static items re-render
|
|
694
|
+
forceRemount();
|
|
712
695
|
}
|
|
713
696
|
if (key.ctrl && input === "t") {
|
|
714
697
|
setIsTaskListVisible((prev) => !prev);
|
|
@@ -765,7 +748,7 @@ export const ChatProvider = ({ children, bypassPermissions, permissionMode: init
|
|
|
765
748
|
handleConfirmationCancel,
|
|
766
749
|
backgroundCurrentTask,
|
|
767
750
|
remountKey,
|
|
768
|
-
|
|
751
|
+
forceRemount,
|
|
769
752
|
handleRewindSelect,
|
|
770
753
|
getFullMessageThread,
|
|
771
754
|
getGatewayConfig,
|
|
@@ -627,6 +627,14 @@ export class AgentBridge {
|
|
|
627
627
|
// ── Auth (global) ────────────────────────────────────────────
|
|
628
628
|
async getAuthStatus() {
|
|
629
629
|
const authService = AuthService.getInstance();
|
|
630
|
+
// A stale-but-refreshable token still means "logged in" — the daemon may
|
|
631
|
+
// have started with an expired access token (hourly expiry) and only
|
|
632
|
+
// refreshes lazily on the first API call. Without this proactive refresh a
|
|
633
|
+
// fresh client querying right after daemon start gets a false
|
|
634
|
+
// isAuthenticated and e.g. the desktop welcome page keeps showing the
|
|
635
|
+
// login button for an authenticated host. Mirrors the refresh that
|
|
636
|
+
// createAuthAwareFetch does before every real request.
|
|
637
|
+
await authService.checkAndRefreshTokenIfNeeded();
|
|
630
638
|
return {
|
|
631
639
|
isAuthenticated: authService.isSSOAuthenticated(),
|
|
632
640
|
user: authService.getAuthUser(),
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "wave-code",
|
|
3
|
-
"version": "1.0.
|
|
3
|
+
"version": "1.0.3",
|
|
4
4
|
"description": "CLI-based code assistant powered by AI, built with React and Ink",
|
|
5
5
|
"repository": {
|
|
6
6
|
"type": "git",
|
|
@@ -43,7 +43,7 @@
|
|
|
43
43
|
"wrap-ansi": "^10.0.0",
|
|
44
44
|
"yargs": "^17.7.2",
|
|
45
45
|
"zod": "^3.23.8",
|
|
46
|
-
"wave-agent-sdk": "1.0.
|
|
46
|
+
"wave-agent-sdk": "1.0.3"
|
|
47
47
|
},
|
|
48
48
|
"devDependencies": {
|
|
49
49
|
"@types/react": "^19.1.8",
|
|
@@ -37,7 +37,7 @@ export const ChatInterface: React.FC = () => {
|
|
|
37
37
|
version,
|
|
38
38
|
workdir,
|
|
39
39
|
remountKey,
|
|
40
|
-
|
|
40
|
+
forceRemount,
|
|
41
41
|
getGatewayConfig,
|
|
42
42
|
} = useChat();
|
|
43
43
|
|
|
@@ -50,8 +50,10 @@ export const ChatInterface: React.FC = () => {
|
|
|
50
50
|
|
|
51
51
|
// Compute whether the user has any usable auth/direct-API config,
|
|
52
52
|
// so the welcome page can prompt /login when neither is present.
|
|
53
|
+
// An SSO token counts as authenticated even if the access token is stale —
|
|
54
|
+
// it refreshes lazily on the next API call (matching the claude-code CLI).
|
|
53
55
|
const computeAuthState = useCallback((): boolean => {
|
|
54
|
-
if (authService.
|
|
56
|
+
if (authService.getSSOToken()) return true;
|
|
55
57
|
const gateway = getGatewayConfig();
|
|
56
58
|
return Boolean(gateway.apiKey || gateway.baseURL);
|
|
57
59
|
}, [getGatewayConfig]);
|
|
@@ -77,14 +79,14 @@ export const ChatInterface: React.FC = () => {
|
|
|
77
79
|
}
|
|
78
80
|
} else if (forceStatic && !hasPendingConfirmations) {
|
|
79
81
|
setForceStatic(false);
|
|
80
|
-
|
|
82
|
+
forceRemount();
|
|
81
83
|
}
|
|
82
84
|
}, [
|
|
83
85
|
isConfirmationVisible,
|
|
84
86
|
terminalHeight,
|
|
85
87
|
forceStatic,
|
|
86
88
|
hasPendingConfirmations,
|
|
87
|
-
|
|
89
|
+
forceRemount,
|
|
88
90
|
]);
|
|
89
91
|
|
|
90
92
|
if (!sessionId) return null;
|
|
@@ -98,7 +100,6 @@ export const ChatInterface: React.FC = () => {
|
|
|
98
100
|
version={version}
|
|
99
101
|
workdir={workdir}
|
|
100
102
|
forceStatic={forceStatic}
|
|
101
|
-
showLoginHint={showLoginHint}
|
|
102
103
|
/>
|
|
103
104
|
|
|
104
105
|
{!isConfirmationVisible && !isExpanded && (
|
|
@@ -126,6 +127,7 @@ export const ChatInterface: React.FC = () => {
|
|
|
126
127
|
hasSlashCommand={hasSlashCommand}
|
|
127
128
|
latestTotalTokens={latestTotalTokens}
|
|
128
129
|
maxInputTokens={maxInputTokens}
|
|
130
|
+
showLoginHint={showLoginHint}
|
|
129
131
|
/>
|
|
130
132
|
</>
|
|
131
133
|
)}
|
|
@@ -14,6 +14,7 @@ import { PluginManagerShell } from "./PluginManagerShell.js";
|
|
|
14
14
|
import { ModelSelector } from "./ModelSelector.js";
|
|
15
15
|
import { WorkflowManager } from "./WorkflowManager.js";
|
|
16
16
|
import { StatusLine } from "./StatusLine.js";
|
|
17
|
+
import { Notifications } from "./Notifications.js";
|
|
17
18
|
import { BtwDisplay } from "./BtwDisplay.js";
|
|
18
19
|
import { useInputManager } from "../hooks/useInputManager.js";
|
|
19
20
|
import { useChat } from "../contexts/useChat.js";
|
|
@@ -49,6 +50,8 @@ export interface InputBoxProps {
|
|
|
49
50
|
// Token usage
|
|
50
51
|
latestTotalTokens?: number;
|
|
51
52
|
maxInputTokens?: number;
|
|
53
|
+
// Login hint
|
|
54
|
+
showLoginHint?: boolean;
|
|
52
55
|
}
|
|
53
56
|
|
|
54
57
|
export const InputBox: React.FC<InputBoxProps> = ({
|
|
@@ -64,6 +67,7 @@ export const InputBox: React.FC<InputBoxProps> = ({
|
|
|
64
67
|
hasSlashCommand = () => false,
|
|
65
68
|
latestTotalTokens = 0,
|
|
66
69
|
maxInputTokens = 200000,
|
|
70
|
+
showLoginHint = false,
|
|
67
71
|
}) => {
|
|
68
72
|
const {
|
|
69
73
|
permissionMode: chatPermissionMode,
|
|
@@ -375,12 +379,17 @@ export const InputBox: React.FC<InputBoxProps> = ({
|
|
|
375
379
|
)}
|
|
376
380
|
</Text>
|
|
377
381
|
</Box>
|
|
378
|
-
<
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
382
|
+
<Box justifyContent="space-between">
|
|
383
|
+
<StatusLine
|
|
384
|
+
permissionMode={permissionMode}
|
|
385
|
+
isShellCommand={isShellCommand}
|
|
386
|
+
/>
|
|
387
|
+
<Notifications
|
|
388
|
+
latestTotalTokens={latestTotalTokens}
|
|
389
|
+
maxInputTokens={maxInputTokens}
|
|
390
|
+
showLoginHint={showLoginHint}
|
|
391
|
+
/>
|
|
392
|
+
</Box>
|
|
384
393
|
</Box>
|
|
385
394
|
)}
|
|
386
395
|
</Box>
|
|
@@ -80,7 +80,9 @@ export const LoginCommand: React.FC<LoginCommandProps> = ({ onCancel }) => {
|
|
|
80
80
|
const handleEnter = async () => {
|
|
81
81
|
if (isLoadingRef.current) return;
|
|
82
82
|
|
|
83
|
-
|
|
83
|
+
// A stale-but-present SSO token still counts as logged in; it refreshes
|
|
84
|
+
// lazily on the next API call. Enter toggles logout only when a token exists.
|
|
85
|
+
const isAuthenticated = Boolean(authService.getSSOToken());
|
|
84
86
|
if (isAuthenticated) {
|
|
85
87
|
await authService.clearAuth();
|
|
86
88
|
setMessage("Logged out successfully");
|
|
@@ -123,7 +125,7 @@ export const LoginCommand: React.FC<LoginCommandProps> = ({ onCancel }) => {
|
|
|
123
125
|
}
|
|
124
126
|
};
|
|
125
127
|
|
|
126
|
-
const isAuthenticated = authService.
|
|
128
|
+
const isAuthenticated = Boolean(authService.getSSOToken());
|
|
127
129
|
const token = authService.getSSOToken();
|
|
128
130
|
const serverUrl = authService.getServerUrl();
|
|
129
131
|
|
|
@@ -13,7 +13,6 @@ export interface MessageListProps {
|
|
|
13
13
|
forceStatic?: boolean;
|
|
14
14
|
version?: string;
|
|
15
15
|
workdir?: string;
|
|
16
|
-
showLoginHint?: boolean;
|
|
17
16
|
}
|
|
18
17
|
|
|
19
18
|
export const MessageList = React.memo(
|
|
@@ -23,7 +22,6 @@ export const MessageList = React.memo(
|
|
|
23
22
|
forceStatic = false,
|
|
24
23
|
version,
|
|
25
24
|
workdir,
|
|
26
|
-
showLoginHint = false,
|
|
27
25
|
}: MessageListProps) => {
|
|
28
26
|
const maxMessages = isExpanded
|
|
29
27
|
? MAX_MESSAGES_EXPANDED
|
|
@@ -137,10 +135,6 @@ export const MessageList = React.memo(
|
|
|
137
135
|
</Static>
|
|
138
136
|
)}
|
|
139
137
|
|
|
140
|
-
{/* Login hint — rendered in the dynamic area (not Static) so it can
|
|
141
|
-
disappear in real time when the user authenticates. */}
|
|
142
|
-
{showLoginHint && <Text color="gray">Type /login to authenticate</Text>}
|
|
143
|
-
|
|
144
138
|
{/* Dynamic blocks */}
|
|
145
139
|
{dynamicBlocks.length > 0 && (
|
|
146
140
|
<Box flexDirection="column">
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import React from "react";
|
|
2
|
+
import { Box, Text } from "ink";
|
|
3
|
+
|
|
4
|
+
export interface NotificationsProps {
|
|
5
|
+
latestTotalTokens?: number;
|
|
6
|
+
maxInputTokens?: number;
|
|
7
|
+
showLoginHint?: boolean;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export const Notifications: React.FC<NotificationsProps> = ({
|
|
11
|
+
latestTotalTokens = 0,
|
|
12
|
+
maxInputTokens = 200000,
|
|
13
|
+
showLoginHint = false,
|
|
14
|
+
}) => {
|
|
15
|
+
const percentage =
|
|
16
|
+
latestTotalTokens > 0
|
|
17
|
+
? Math.min(Math.round((latestTotalTokens / maxInputTokens) * 100), 100)
|
|
18
|
+
: 0;
|
|
19
|
+
|
|
20
|
+
const contextColor =
|
|
21
|
+
percentage > 95 ? "red" : percentage > 80 ? "yellow" : "gray";
|
|
22
|
+
|
|
23
|
+
return (
|
|
24
|
+
<Box gap={1}>
|
|
25
|
+
{showLoginHint && <Text color="gray">Type /login to authenticate</Text>}
|
|
26
|
+
{percentage > 0 && (
|
|
27
|
+
<Text color={contextColor}>{percentage}% context</Text>
|
|
28
|
+
)}
|
|
29
|
+
</Box>
|
|
30
|
+
);
|
|
31
|
+
};
|
|
@@ -4,52 +4,35 @@ import { Box, Text } from "ink";
|
|
|
4
4
|
export interface StatusLineProps {
|
|
5
5
|
permissionMode: string;
|
|
6
6
|
isShellCommand: boolean;
|
|
7
|
-
latestTotalTokens?: number;
|
|
8
|
-
maxInputTokens?: number;
|
|
9
7
|
}
|
|
10
8
|
|
|
11
9
|
export const StatusLine: React.FC<StatusLineProps> = ({
|
|
12
10
|
permissionMode,
|
|
13
11
|
isShellCommand,
|
|
14
|
-
latestTotalTokens = 0,
|
|
15
|
-
maxInputTokens = 200000,
|
|
16
12
|
}) => {
|
|
17
|
-
const percentage =
|
|
18
|
-
latestTotalTokens > 0
|
|
19
|
-
? Math.min(Math.round((latestTotalTokens / maxInputTokens) * 100), 100)
|
|
20
|
-
: 0;
|
|
21
|
-
|
|
22
|
-
const contextColor =
|
|
23
|
-
percentage > 95 ? "red" : percentage > 80 ? "yellow" : "gray";
|
|
24
|
-
|
|
25
13
|
return (
|
|
26
|
-
<Box
|
|
14
|
+
<Box>
|
|
27
15
|
{isShellCommand ? (
|
|
28
16
|
<Text color="gray">
|
|
29
17
|
Shell: <Text color="yellow">Run shell command</Text>
|
|
30
18
|
</Text>
|
|
31
19
|
) : (
|
|
32
|
-
<
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
</Text>
|
|
49
|
-
</Box>
|
|
50
|
-
)}
|
|
51
|
-
{percentage > 0 && (
|
|
52
|
-
<Text color={contextColor}>{percentage}% context</Text>
|
|
20
|
+
<Text color="gray">
|
|
21
|
+
Mode:{" "}
|
|
22
|
+
<Text
|
|
23
|
+
color={
|
|
24
|
+
permissionMode === "plan"
|
|
25
|
+
? "yellow"
|
|
26
|
+
: permissionMode === "bypassPermissions"
|
|
27
|
+
? "red"
|
|
28
|
+
: "cyan"
|
|
29
|
+
}
|
|
30
|
+
bold={permissionMode === "bypassPermissions"}
|
|
31
|
+
>
|
|
32
|
+
{permissionMode}
|
|
33
|
+
</Text>{" "}
|
|
34
|
+
(Shift+Tab to cycle)
|
|
35
|
+
</Text>
|
|
53
36
|
)}
|
|
54
37
|
</Box>
|
|
55
38
|
);
|
package/src/contexts/useChat.tsx
CHANGED
|
@@ -122,7 +122,7 @@ export interface ChatContextType {
|
|
|
122
122
|
backgroundCurrentTask: () => void;
|
|
123
123
|
// Remount functionality
|
|
124
124
|
remountKey: number;
|
|
125
|
-
|
|
125
|
+
forceRemount: () => void;
|
|
126
126
|
// Rewind functionality
|
|
127
127
|
handleRewindSelect: (index: number) => Promise<void>;
|
|
128
128
|
getFullMessageThread: () => Promise<{
|
|
@@ -387,42 +387,15 @@ export const ChatProvider: React.FC<ChatProviderProps> = ({
|
|
|
387
387
|
|
|
388
388
|
// Remount state
|
|
389
389
|
const [remountKey, setRemountKey] = useState(0);
|
|
390
|
-
const prevSessionId = useRef<string | null>(null);
|
|
391
390
|
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
},
|
|
401
|
-
1000,
|
|
402
|
-
{ leading: true, trailing: false },
|
|
403
|
-
),
|
|
404
|
-
[stdout],
|
|
405
|
-
);
|
|
406
|
-
|
|
407
|
-
useEffect(() => {
|
|
408
|
-
return () => {
|
|
409
|
-
requestRemount.cancel();
|
|
410
|
-
};
|
|
411
|
-
}, [requestRemount]);
|
|
412
|
-
|
|
413
|
-
// Track sessionId changes to trigger remount
|
|
414
|
-
useEffect(() => {
|
|
415
|
-
if (
|
|
416
|
-
prevSessionId.current &&
|
|
417
|
-
sessionId &&
|
|
418
|
-
prevSessionId.current !== sessionId
|
|
419
|
-
) {
|
|
420
|
-
requestRemount();
|
|
421
|
-
}
|
|
422
|
-
if (sessionId) {
|
|
423
|
-
prevSessionId.current = sessionId;
|
|
424
|
-
}
|
|
425
|
-
}, [sessionId, requestRemount]);
|
|
391
|
+
// Full terminal clear + remount so Ink's append-only <Static> re-renders.
|
|
392
|
+
// Used on structural actions (/clear, /compact, rewind, ctrl-o, forceStatic
|
|
393
|
+
// exit) where stale Static output must not linger on screen.
|
|
394
|
+
const forceRemount = useCallback(() => {
|
|
395
|
+
stdout?.write("\u001b[2J\u001b[3J\u001b[0;0H", () => {
|
|
396
|
+
setRemountKey((prev) => prev + 1);
|
|
397
|
+
});
|
|
398
|
+
}, [stdout]);
|
|
426
399
|
|
|
427
400
|
// Status metadata state
|
|
428
401
|
const [workingDirectory, setWorkingDirectory] = useState("");
|
|
@@ -876,14 +849,16 @@ export const ChatProvider: React.FC<ChatProviderProps> = ({
|
|
|
876
849
|
const clearMessages = useCallback(async () => {
|
|
877
850
|
await agentRef.current?.clearMessages();
|
|
878
851
|
refreshMessages();
|
|
879
|
-
|
|
852
|
+
forceRemount();
|
|
853
|
+
}, [refreshMessages, forceRemount]);
|
|
880
854
|
|
|
881
855
|
const compact = useCallback(
|
|
882
856
|
async (instructions?: string) => {
|
|
883
857
|
await agentRef.current?.compact(instructions);
|
|
884
858
|
refreshMessages();
|
|
859
|
+
forceRemount();
|
|
885
860
|
},
|
|
886
|
-
[refreshMessages],
|
|
861
|
+
[refreshMessages, forceRemount],
|
|
887
862
|
);
|
|
888
863
|
|
|
889
864
|
// Unified interrupt method, interrupt both AI messages and command execution
|
|
@@ -995,13 +970,13 @@ export const ChatProvider: React.FC<ChatProviderProps> = ({
|
|
|
995
970
|
try {
|
|
996
971
|
await agentRef.current.truncateHistory(index);
|
|
997
972
|
refreshMessages();
|
|
998
|
-
|
|
973
|
+
forceRemount();
|
|
999
974
|
} catch (error) {
|
|
1000
975
|
logger.error("Failed to rewind:", error);
|
|
1001
976
|
}
|
|
1002
977
|
}
|
|
1003
978
|
},
|
|
1004
|
-
[
|
|
979
|
+
[forceRemount, refreshMessages],
|
|
1005
980
|
);
|
|
1006
981
|
|
|
1007
982
|
const getFullMessageThread = useCallback(async () => {
|
|
@@ -1054,12 +1029,8 @@ export const ChatProvider: React.FC<ChatProviderProps> = ({
|
|
|
1054
1029
|
// Transitioning to COLLAPSED: Restore from agent's actual state
|
|
1055
1030
|
refreshMessages();
|
|
1056
1031
|
}
|
|
1057
|
-
// Force remount
|
|
1058
|
-
|
|
1059
|
-
// a previous remount, leaving the UI stuck without a visual update
|
|
1060
|
-
stdout?.write("\u001b[2J\u001b[3J\u001b[0;0H", () => {
|
|
1061
|
-
setRemountKey((prev) => prev + 1);
|
|
1062
|
-
});
|
|
1032
|
+
// Force remount to ensure Static items re-render
|
|
1033
|
+
forceRemount();
|
|
1063
1034
|
}
|
|
1064
1035
|
|
|
1065
1036
|
if (key.ctrl && input === "t") {
|
|
@@ -1119,7 +1090,7 @@ export const ChatProvider: React.FC<ChatProviderProps> = ({
|
|
|
1119
1090
|
handleConfirmationCancel,
|
|
1120
1091
|
backgroundCurrentTask,
|
|
1121
1092
|
remountKey,
|
|
1122
|
-
|
|
1093
|
+
forceRemount,
|
|
1123
1094
|
handleRewindSelect,
|
|
1124
1095
|
getFullMessageThread,
|
|
1125
1096
|
|
package/src/stdio/agentBridge.ts
CHANGED
|
@@ -1057,6 +1057,14 @@ export class AgentBridge {
|
|
|
1057
1057
|
serverUrl: string;
|
|
1058
1058
|
}> {
|
|
1059
1059
|
const authService = AuthService.getInstance();
|
|
1060
|
+
// A stale-but-refreshable token still means "logged in" — the daemon may
|
|
1061
|
+
// have started with an expired access token (hourly expiry) and only
|
|
1062
|
+
// refreshes lazily on the first API call. Without this proactive refresh a
|
|
1063
|
+
// fresh client querying right after daemon start gets a false
|
|
1064
|
+
// isAuthenticated and e.g. the desktop welcome page keeps showing the
|
|
1065
|
+
// login button for an authenticated host. Mirrors the refresh that
|
|
1066
|
+
// createAuthAwareFetch does before every real request.
|
|
1067
|
+
await authService.checkAndRefreshTokenIfNeeded();
|
|
1060
1068
|
return {
|
|
1061
1069
|
isAuthenticated: authService.isSSOAuthenticated(),
|
|
1062
1070
|
user: authService.getAuthUser(),
|