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.
@@ -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, requestRemount, getGatewayConfig, } = useChat();
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.isSSOAuthenticated())
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
- requestRemount();
49
+ forceRemount();
48
50
  }
49
51
  }, [
50
52
  isConfirmationVisible,
51
53
  terminalHeight,
52
54
  forceStatic,
53
55
  hasPendingConfirmations,
54
- requestRemount,
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, showLoginHint: showLoginHint }, 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 })] })), 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 })] }))] }));
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
  };
@@ -19,5 +19,6 @@ export interface InputBoxProps {
19
19
  hasSlashCommand?: (commandId: string) => boolean;
20
20
  latestTotalTokens?: number;
21
21
  maxInputTokens?: number;
22
+ showLoginHint?: boolean;
22
23
  }
23
24
  export declare const InputBox: React.FC<InputBoxProps>;
@@ -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
- const isAuthenticated = authService.isSSOAuthenticated();
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.isSSOAuthenticated();
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, showLoginHint, }: MessageListProps) => import("react/jsx-runtime").JSX.Element>;
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, showLoginHint = false, }) => {
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
- } })), showLoginHint && _jsx(Text, { color: "gray", children: "Type /login to authenticate" }), 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))) }))] }));
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,7 @@
1
+ import React from "react";
2
+ export interface NotificationsProps {
3
+ latestTotalTokens?: number;
4
+ maxInputTokens?: number;
5
+ showLoginHint?: boolean;
6
+ }
7
+ export declare const Notifications: React.FC<NotificationsProps>;
@@ -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
+ };
@@ -2,7 +2,5 @@ import React from "react";
2
2
  export interface StatusLineProps {
3
3
  permissionMode: string;
4
4
  isShellCommand: boolean;
5
- latestTotalTokens?: number;
6
- maxInputTokens?: number;
7
5
  }
8
6
  export declare const StatusLine: React.FC<StatusLineProps>;
@@ -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, latestTotalTokens = 0, maxInputTokens = 200000, }) => {
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, { paddingRight: 1, justifyContent: "space-between", width: "100%", children: [isShellCommand ? (_jsxs(Text, { color: "gray", children: ["Shell: ", _jsx(Text, { color: "yellow", children: "Run shell command" })] })) : (_jsx(Box, { children: _jsxs(Text, { color: "gray", children: ["Mode:", " ", _jsx(Text, { color: permissionMode === "plan"
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
- requestRemount: () => void;
65
+ forceRemount: () => void;
66
66
  handleRewindSelect: (index: number) => Promise<void>;
67
67
  getFullMessageThread: () => Promise<{
68
68
  messages: Message[];
@@ -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
- const prevSessionId = useRef(null);
151
- const requestRemount = useMemo(() => throttle(() => {
152
- logger.debug("requesting remount");
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
- }, 1000, { leading: true, trailing: false }), [stdout]);
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
- }, [refreshMessages]);
541
+ forceRemount();
542
+ }, [refreshMessages, forceRemount]);
557
543
  const compact = useCallback(async (instructions) => {
558
544
  await agentRef.current?.compact(instructions);
559
545
  refreshMessages();
560
- }, [refreshMessages]);
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
- requestRemount();
641
+ forceRemount();
655
642
  }
656
643
  catch (error) {
657
644
  logger.error("Failed to rewind:", error);
658
645
  }
659
646
  }
660
- }, [requestRemount, refreshMessages]);
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 directly (bypass throttle) to ensure Static items re-render
707
- // The throttled requestRemount can be dropped if pressed too quickly after
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
- requestRemount: requestRemount,
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.1",
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.1"
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
- requestRemount,
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.isSSOAuthenticated()) return true;
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
- requestRemount();
82
+ forceRemount();
81
83
  }
82
84
  }, [
83
85
  isConfirmationVisible,
84
86
  terminalHeight,
85
87
  forceStatic,
86
88
  hasPendingConfirmations,
87
- requestRemount,
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
- <StatusLine
379
- permissionMode={permissionMode}
380
- isShellCommand={isShellCommand}
381
- latestTotalTokens={latestTotalTokens}
382
- maxInputTokens={maxInputTokens}
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
- const isAuthenticated = authService.isSSOAuthenticated();
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.isSSOAuthenticated();
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 paddingRight={1} justifyContent="space-between" width="100%">
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
- <Box>
33
- <Text color="gray">
34
- Mode:{" "}
35
- <Text
36
- color={
37
- permissionMode === "plan"
38
- ? "yellow"
39
- : permissionMode === "bypassPermissions"
40
- ? "red"
41
- : "cyan"
42
- }
43
- bold={permissionMode === "bypassPermissions"}
44
- >
45
- {permissionMode}
46
- </Text>{" "}
47
- (Shift+Tab to cycle)
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
  );
@@ -122,7 +122,7 @@ export interface ChatContextType {
122
122
  backgroundCurrentTask: () => void;
123
123
  // Remount functionality
124
124
  remountKey: number;
125
- requestRemount: () => void;
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
- const requestRemount = useMemo(
393
- () =>
394
- throttle(
395
- () => {
396
- logger.debug("requesting remount");
397
- stdout?.write("\u001b[2J\u001b[3J\u001b[0;0H", () => {
398
- setRemountKey((prev) => prev + 1);
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
- }, [refreshMessages]);
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
- requestRemount();
973
+ forceRemount();
999
974
  } catch (error) {
1000
975
  logger.error("Failed to rewind:", error);
1001
976
  }
1002
977
  }
1003
978
  },
1004
- [requestRemount, refreshMessages],
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 directly (bypass throttle) to ensure Static items re-render
1058
- // The throttled requestRemount can be dropped if pressed too quickly after
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
- requestRemount: requestRemount as () => void,
1093
+ forceRemount,
1123
1094
  handleRewindSelect,
1124
1095
  getFullMessageThread,
1125
1096
 
@@ -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(),