rework /resume slash command

This commit is contained in:
tanzhenxin
2025-12-16 19:54:55 +08:00
parent 9942b2b877
commit 2837aa6b7c
16 changed files with 724 additions and 1232 deletions

View File

@@ -56,6 +56,7 @@ const SLASH_COMMANDS_SKIP_RECORDING = new Set([
'clear',
'reset',
'new',
'resume',
]);
interface SlashCommandProcessorActions {

View File

@@ -5,25 +5,28 @@
*/
/**
* Session picker hook for dialog mode (within main app).
* Uses useKeypress (KeypressContext) instead of useInput (ink).
* For standalone mode, use useSessionPicker instead.
* Unified session picker hook for both dialog and standalone modes.
*
* IMPORTANT:
* - Uses KeypressContext (`useKeypress`) so it behaves correctly inside the main app.
* - Standalone mode should wrap the picker in `<KeypressProvider>` when rendered
* outside the main app.
*/
import { useState, useEffect, useCallback, useRef } from 'react';
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import type {
SessionService,
SessionListItem,
ListSessionsResult,
SessionListItem,
SessionService,
} from '@qwen-code/qwen-code-core';
import {
SESSION_PAGE_SIZE,
filterSessions,
SESSION_PAGE_SIZE,
type SessionState,
} from '../utils/sessionPickerUtils.js';
import { useKeypress } from './useKeypress.js';
export interface UseDialogSessionPickerOptions {
export interface UseSessionPickerOptions {
sessionService: SessionService | null;
currentBranch?: string;
onSelect: (sessionId: string) => void;
@@ -40,8 +43,7 @@ export interface UseDialogSessionPickerOptions {
isActive?: boolean;
}
export interface UseDialogSessionPickerResult {
// State
export interface UseSessionPickerResult {
selectedIndex: number;
sessionState: SessionState;
filteredSessions: SessionListItem[];
@@ -51,12 +53,10 @@ export interface UseDialogSessionPickerResult {
visibleSessions: SessionListItem[];
showScrollUp: boolean;
showScrollDown: boolean;
// Actions
loadMoreSessions: () => Promise<void>;
}
export function useDialogSessionPicker({
export function useSessionPicker({
sessionService,
currentBranch,
onSelect,
@@ -64,7 +64,7 @@ export function useDialogSessionPicker({
maxVisibleItems,
centerSelection = false,
isActive = true,
}: UseDialogSessionPickerOptions): UseDialogSessionPickerResult {
}: UseSessionPickerOptions): UseSessionPickerResult {
const [selectedIndex, setSelectedIndex] = useState(0);
const [sessionState, setSessionState] = useState<SessionState>({
sessions: [],
@@ -73,43 +73,47 @@ export function useDialogSessionPicker({
});
const [filterByBranch, setFilterByBranch] = useState(false);
const [isLoading, setIsLoading] = useState(true);
// For follow mode (non-centered)
const [followScrollOffset, setFollowScrollOffset] = useState(0);
const isLoadingMoreRef = useRef(false);
// Filter sessions
const filteredSessions = filterSessions(
sessionState.sessions,
filterByBranch,
currentBranch,
const filteredSessions = useMemo(
() => filterSessions(sessionState.sessions, filterByBranch, currentBranch),
[sessionState.sessions, filterByBranch, currentBranch],
);
// Calculate scroll offset based on mode
const scrollOffset = centerSelection
? (() => {
if (filteredSessions.length <= maxVisibleItems) {
return 0;
}
const halfVisible = Math.floor(maxVisibleItems / 2);
let offset = selectedIndex - halfVisible;
offset = Math.max(0, offset);
offset = Math.min(filteredSessions.length - maxVisibleItems, offset);
return offset;
})()
: followScrollOffset;
const scrollOffset = useMemo(() => {
if (centerSelection) {
if (filteredSessions.length <= maxVisibleItems) {
return 0;
}
const halfVisible = Math.floor(maxVisibleItems / 2);
let offset = selectedIndex - halfVisible;
offset = Math.max(0, offset);
offset = Math.min(filteredSessions.length - maxVisibleItems, offset);
return offset;
}
return followScrollOffset;
}, [
centerSelection,
filteredSessions.length,
followScrollOffset,
maxVisibleItems,
selectedIndex,
]);
const visibleSessions = filteredSessions.slice(
scrollOffset,
scrollOffset + maxVisibleItems,
const visibleSessions = useMemo(
() => filteredSessions.slice(scrollOffset, scrollOffset + maxVisibleItems),
[filteredSessions, maxVisibleItems, scrollOffset],
);
const showScrollUp = scrollOffset > 0;
const showScrollDown =
scrollOffset + maxVisibleItems < filteredSessions.length;
// Load initial sessions
// Initial load
useEffect(() => {
// Guard: don't load if sessionService is not ready
if (!sessionService) {
return;
}
@@ -128,10 +132,10 @@ export function useDialogSessionPicker({
setIsLoading(false);
}
};
loadInitialSessions();
void loadInitialSessions();
}, [sessionService]);
// Load more sessions
const loadMoreSessions = useCallback(async () => {
if (!sessionService || !sessionState.hasMore || isLoadingMoreRef.current) {
return;
@@ -169,9 +173,8 @@ export function useDialogSessionPicker({
}
}, [filteredSessions.length, selectedIndex]);
// Auto-load more when list is empty or near end (for centered mode)
// Auto-load more when centered mode hits the sentinel or list is empty.
useEffect(() => {
// Don't auto-load during initial load or if not in centered mode
if (
isLoading ||
!sessionState.hasMore ||
@@ -182,7 +185,6 @@ export function useDialogSessionPicker({
}
const sentinelVisible =
sessionState.hasMore &&
scrollOffset + maxVisibleItems >= filteredSessions.length;
const shouldLoadMore = filteredSessions.length === 0 || sentinelVisible;
@@ -190,27 +192,25 @@ export function useDialogSessionPicker({
void loadMoreSessions();
}
}, [
isLoading,
filteredSessions.length,
loadMoreSessions,
sessionState.hasMore,
scrollOffset,
maxVisibleItems,
centerSelection,
filteredSessions.length,
isLoading,
loadMoreSessions,
maxVisibleItems,
scrollOffset,
sessionState.hasMore,
]);
// Handle keyboard input using useKeypress (KeypressContext)
// Key handling (KeypressContext)
useKeypress(
(key) => {
const { name, sequence, ctrl } = key;
// Escape or Ctrl+C to cancel
if (name === 'escape' || (ctrl && name === 'c')) {
onCancel();
return;
}
// Enter to select
if (name === 'return') {
const session = filteredSessions[selectedIndex];
if (session) {
@@ -219,11 +219,9 @@ export function useDialogSessionPicker({
return;
}
// Navigation up
if (name === 'up' || name === 'k') {
setSelectedIndex((prev) => {
const newIndex = Math.max(0, prev - 1);
// Adjust scroll offset if needed (for follow mode)
if (!centerSelection && newIndex < followScrollOffset) {
setFollowScrollOffset(newIndex);
}
@@ -232,7 +230,6 @@ export function useDialogSessionPicker({
return;
}
// Navigation down
if (name === 'down' || name === 'j') {
if (filteredSessions.length === 0) {
return;
@@ -240,28 +237,28 @@ export function useDialogSessionPicker({
setSelectedIndex((prev) => {
const newIndex = Math.min(filteredSessions.length - 1, prev + 1);
// Adjust scroll offset if needed (for follow mode)
if (
!centerSelection &&
newIndex >= followScrollOffset + maxVisibleItems
) {
setFollowScrollOffset(newIndex - maxVisibleItems + 1);
}
// Load more if near the end
if (newIndex >= filteredSessions.length - 3 && sessionState.hasMore) {
loadMoreSessions();
// Follow mode: load more when near the end.
if (!centerSelection && newIndex >= filteredSessions.length - 3) {
void loadMoreSessions();
}
return newIndex;
});
return;
}
// Toggle branch filter
if (sequence === 'b' || sequence === 'B') {
if (currentBranch) {
setFilterByBranch((prev) => !prev);
}
return;
}
},
{ isActive },

View File

@@ -0,0 +1,97 @@
/**
* @license
* Copyright 2025 Qwen Code
* SPDX-License-Identifier: Apache-2.0
*/
import { act, renderHook } from '@testing-library/react';
import { describe, it, expect, vi } from 'vitest';
import { useSessionSelect } from './useSessionSelect.js';
vi.mock('../utils/resumeHistoryUtils.js', () => ({
buildResumedHistoryItems: vi.fn(() => [{ id: 1, type: 'user', text: 'hi' }]),
}));
vi.mock('@qwen-code/qwen-code-core', () => {
class SessionService {
constructor(_cwd: string) {}
async loadSession(_sessionId: string) {
return { conversation: [{ role: 'user', parts: [{ text: 'hello' }] }] };
}
}
return {
SessionService,
buildApiHistoryFromConversation: vi.fn(() => [{ role: 'user', parts: [] }]),
replayUiTelemetryFromConversation: vi.fn(),
uiTelemetryService: { reset: vi.fn() },
};
});
describe('useSessionSelect', () => {
it('no-ops when config is null', async () => {
const closeResumeDialog = vi.fn();
const historyManager = { clearItems: vi.fn(), loadHistory: vi.fn() };
const startNewSession = vi.fn();
const { result } = renderHook(() =>
useSessionSelect({
config: null,
closeResumeDialog,
historyManager,
startNewSession,
}),
);
await act(async () => {
await result.current('session-1');
});
expect(closeResumeDialog).not.toHaveBeenCalled();
expect(startNewSession).not.toHaveBeenCalled();
expect(historyManager.clearItems).not.toHaveBeenCalled();
expect(historyManager.loadHistory).not.toHaveBeenCalled();
});
it('closes the dialog immediately and restores session state', async () => {
const closeResumeDialog = vi.fn();
const historyManager = { clearItems: vi.fn(), loadHistory: vi.fn() };
const startNewSession = vi.fn();
const geminiClient = {
initialize: vi.fn(),
};
const config = {
getTargetDir: () => '/tmp',
getGeminiClient: () => geminiClient,
startNewSession: vi.fn(),
} as unknown as import('@qwen-code/qwen-code-core').Config;
const { result } = renderHook(() =>
useSessionSelect({
config,
closeResumeDialog,
historyManager,
startNewSession,
}),
);
const resumePromise = act(async () => {
await result.current('session-2');
});
expect(closeResumeDialog).toHaveBeenCalledTimes(1);
await resumePromise;
expect(config.startNewSession).toHaveBeenCalledWith(
'session-2',
expect.objectContaining({
conversation: expect.anything(),
}),
);
expect(startNewSession).toHaveBeenCalledWith('session-2');
expect(geminiClient.initialize).toHaveBeenCalledTimes(1);
expect(historyManager.clearItems).toHaveBeenCalledTimes(1);
expect(historyManager.loadHistory).toHaveBeenCalledTimes(1);
});
});

View File

@@ -0,0 +1,64 @@
/**
* @license
* Copyright 2025 Qwen Code
* SPDX-License-Identifier: Apache-2.0
*/
import { useCallback } from 'react';
import { SessionService, type Config } from '@qwen-code/qwen-code-core';
import { buildResumedHistoryItems } from '../utils/resumeHistoryUtils.js';
import type { UseHistoryManagerReturn } from './useHistoryManager.js';
export interface UseSessionSelectOptions {
config: Config | null;
historyManager: Pick<UseHistoryManagerReturn, 'clearItems' | 'loadHistory'>;
closeResumeDialog: () => void;
startNewSession: (sessionId: string) => void;
remount?: () => void;
}
/**
* Returns a stable callback to resume a saved session and restore UI + client state.
*/
export function useSessionSelect({
config,
closeResumeDialog,
historyManager,
startNewSession,
remount,
}: UseSessionSelectOptions): (sessionId: string) => void {
return useCallback(
async (sessionId: string) => {
if (!config) {
return;
}
// Close dialog immediately to prevent input capture during async operations.
closeResumeDialog();
const cwd = config.getTargetDir();
const sessionService = new SessionService(cwd);
const sessionData = await sessionService.loadSession(sessionId);
if (!sessionData) {
return;
}
// Start new session in UI context.
startNewSession(sessionId);
// Reset UI history.
const uiHistoryItems = buildResumedHistoryItems(sessionData, config);
historyManager.clearItems();
historyManager.loadHistory(uiHistoryItems);
// Update session history core.
config.startNewSession(sessionId, sessionData);
await config.getGeminiClient()?.initialize?.();
// Refresh terminal UI.
remount?.();
},
[closeResumeDialog, config, historyManager, startNewSession, remount],
);
}

View File

@@ -1,287 +0,0 @@
/**
* @license
* Copyright 2025 Qwen Code
* SPDX-License-Identifier: Apache-2.0
*/
/**
* Session picker hook for standalone mode (fullscreen CLI picker).
* Uses useInput (ink) instead of useKeypress (KeypressContext).
* For dialog mode within the main app, use useDialogSessionPicker instead.
*/
import { useState, useEffect, useCallback, useRef } from 'react';
import { useInput } from 'ink';
import type {
SessionService,
SessionListItem,
ListSessionsResult,
} from '@qwen-code/qwen-code-core';
import {
SESSION_PAGE_SIZE,
filterSessions,
type SessionState,
} from '../utils/sessionPickerUtils.js';
export interface UseSessionPickerOptions {
sessionService: SessionService | null;
currentBranch?: string;
onSelect: (sessionId: string) => void;
onCancel: () => void;
maxVisibleItems: number;
/**
* If true, computes centered scroll offset (keeps selection near middle).
* If false, uses follow mode (scrolls when selection reaches edge).
*/
centerSelection?: boolean;
/**
* Optional callback when exiting (for standalone mode).
*/
onExit?: () => void;
/**
* Enable/disable input handling.
*/
isActive?: boolean;
}
export interface UseSessionPickerResult {
// State
selectedIndex: number;
sessionState: SessionState;
filteredSessions: SessionListItem[];
filterByBranch: boolean;
isLoading: boolean;
scrollOffset: number;
visibleSessions: SessionListItem[];
showScrollUp: boolean;
showScrollDown: boolean;
// Actions
loadMoreSessions: () => Promise<void>;
}
export function useSessionPicker({
sessionService,
currentBranch,
onSelect,
onCancel,
maxVisibleItems,
centerSelection = false,
onExit,
isActive = true,
}: UseSessionPickerOptions): UseSessionPickerResult {
const [selectedIndex, setSelectedIndex] = useState(0);
const [sessionState, setSessionState] = useState<SessionState>({
sessions: [],
hasMore: true,
nextCursor: undefined,
});
const [filterByBranch, setFilterByBranch] = useState(false);
const [isLoading, setIsLoading] = useState(true);
// For follow mode (non-centered)
const [followScrollOffset, setFollowScrollOffset] = useState(0);
const isLoadingMoreRef = useRef(false);
// Filter sessions
const filteredSessions = filterSessions(
sessionState.sessions,
filterByBranch,
currentBranch,
);
// Calculate scroll offset based on mode
const scrollOffset = centerSelection
? (() => {
if (filteredSessions.length <= maxVisibleItems) {
return 0;
}
const halfVisible = Math.floor(maxVisibleItems / 2);
let offset = selectedIndex - halfVisible;
offset = Math.max(0, offset);
offset = Math.min(filteredSessions.length - maxVisibleItems, offset);
return offset;
})()
: followScrollOffset;
const visibleSessions = filteredSessions.slice(
scrollOffset,
scrollOffset + maxVisibleItems,
);
const showScrollUp = scrollOffset > 0;
const showScrollDown =
scrollOffset + maxVisibleItems < filteredSessions.length;
// Load initial sessions
useEffect(() => {
// Guard: don't load if sessionService is not ready
if (!sessionService) {
return;
}
const loadInitialSessions = async () => {
try {
const result: ListSessionsResult = await sessionService.listSessions({
size: SESSION_PAGE_SIZE,
});
setSessionState({
sessions: result.items,
hasMore: result.hasMore,
nextCursor: result.nextCursor,
});
} finally {
setIsLoading(false);
}
};
loadInitialSessions();
}, [sessionService]);
// Load more sessions
const loadMoreSessions = useCallback(async () => {
if (!sessionService || !sessionState.hasMore || isLoadingMoreRef.current) {
return;
}
isLoadingMoreRef.current = true;
try {
const result: ListSessionsResult = await sessionService.listSessions({
size: SESSION_PAGE_SIZE,
cursor: sessionState.nextCursor,
});
setSessionState((prev) => ({
sessions: [...prev.sessions, ...result.items],
hasMore: result.hasMore && result.nextCursor !== undefined,
nextCursor: result.nextCursor,
}));
} finally {
isLoadingMoreRef.current = false;
}
}, [sessionService, sessionState.hasMore, sessionState.nextCursor]);
// Reset selection when filter changes
useEffect(() => {
setSelectedIndex(0);
setFollowScrollOffset(0);
}, [filterByBranch]);
// Ensure selectedIndex is valid when filtered sessions change
useEffect(() => {
if (
selectedIndex >= filteredSessions.length &&
filteredSessions.length > 0
) {
setSelectedIndex(filteredSessions.length - 1);
}
}, [filteredSessions.length, selectedIndex]);
// Auto-load more when list is empty or near end (for centered mode)
useEffect(() => {
// Don't auto-load during initial load or if not in centered mode
if (
isLoading ||
!sessionState.hasMore ||
isLoadingMoreRef.current ||
!centerSelection
) {
return;
}
const sentinelVisible =
sessionState.hasMore &&
scrollOffset + maxVisibleItems >= filteredSessions.length;
const shouldLoadMore = filteredSessions.length === 0 || sentinelVisible;
if (shouldLoadMore) {
void loadMoreSessions();
}
}, [
isLoading,
filteredSessions.length,
loadMoreSessions,
sessionState.hasMore,
scrollOffset,
maxVisibleItems,
centerSelection,
]);
// Handle keyboard input
useInput(
(input, key) => {
// Escape or Ctrl+C to cancel
if (key.escape || (key.ctrl && input === 'c')) {
onCancel();
onExit?.();
return;
}
// Enter to select
if (key.return) {
const session = filteredSessions[selectedIndex];
if (session) {
onSelect(session.sessionId);
onExit?.();
}
return;
}
// Navigation up
if (key.upArrow || input === 'k') {
setSelectedIndex((prev) => {
const newIndex = Math.max(0, prev - 1);
// Adjust scroll offset if needed (for follow mode)
if (!centerSelection && newIndex < followScrollOffset) {
setFollowScrollOffset(newIndex);
}
return newIndex;
});
return;
}
// Navigation down
if (key.downArrow || input === 'j') {
if (filteredSessions.length === 0) {
return;
}
setSelectedIndex((prev) => {
const newIndex = Math.min(filteredSessions.length - 1, prev + 1);
// Adjust scroll offset if needed (for follow mode)
if (
!centerSelection &&
newIndex >= followScrollOffset + maxVisibleItems
) {
setFollowScrollOffset(newIndex - maxVisibleItems + 1);
}
// Load more if near the end
if (newIndex >= filteredSessions.length - 3 && sessionState.hasMore) {
loadMoreSessions();
}
return newIndex;
});
return;
}
// Toggle branch filter
if (input === 'b' || input === 'B') {
if (currentBranch) {
setFilterByBranch((prev) => !prev);
}
return;
}
},
{ isActive },
);
return {
selectedIndex,
sessionState,
filteredSessions,
filterByBranch,
isLoading,
scrollOffset,
visibleSessions,
showScrollUp,
showScrollDown,
loadMoreSessions,
};
}