diff --git a/docs/superpowers/plans/2026-07-17-at-session-reference.md b/docs/superpowers/plans/2026-07-17-at-session-reference.md new file mode 100644 index 00000000000..d798ba2c863 --- /dev/null +++ b/docs/superpowers/plans/2026-07-17-at-session-reference.md @@ -0,0 +1,1136 @@ +# `@` Session Reference + Tabbed Completion UI — Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Let a user reference a prior chat session via `@`, injecting a deterministically-slimmed copy of its history as read-only context, and redesign the `@` completion dropdown into a tab-switched layout. + +**Architecture:** Backend is a pure, unit-testable core service (`SessionReferenceService`) plus a ref parser (`session-mention-ref`); it loads a session via the existing `SessionService`, slims records to user/assistant text + one-line tool summaries, and tail-trims to a fixed token budget. `atCommandProcessor` gains a `@session:` routing branch that injects the slimmed block as a scoped-mention part. Frontend adds a `category` field to `Suggestion`, a session-suggestion producer in `useAtCompletion`, and a tab bar in `SuggestionsDisplay` driven by a new tab-switch keybinding. + +**Tech Stack:** TypeScript, React + Ink (TUI), Vitest, existing qwen-code `SessionService` / `atCommandProcessor` / completion hooks. + +## Global Constraints + +- Referenceable scope: **current project only** — rely on `SessionService.loadSession` / `listSessions`, which already enforce `sessionBelongsToCurrentProject`. Never scan other projects' chat dirs. +- Slimming is **deterministic, no LLM/model call**. Do not import `runSideQuery` or `ChatCompressionService`. +- Slimming keeps **user + assistant visible text** and a **one-line summary per tool call** (`[tool: ]`); never include tool result bodies. +- Injected size cap: **fixed token budget with tail-retention** — drop oldest turns first, prepend `[earlier turns omitted]`, set `truncated: true`. +- Unresolved / not-found / cross-project refs: **fall back to literal text with a surfaced note**, never silently drop. +- No AI-authorship trailers in any commit message (`QwenLM/qwen-code` house rule). +- Follow existing patterns: mirror `extension-mention-ref.ts` for the ref parser and producer; mirror `StatsDialog.tsx` tab trio for the tab UI. +- Commit style: Conventional Commits (`feat:`, `test:`, `refactor:`). + +--- + +### Task 1: `session-mention-ref` — parse/build/validate `@session:` refs + +**Files:** + +- Create: `packages/cli/src/ui/hooks/session-mention-ref.ts` +- Test: `packages/cli/src/ui/hooks/session-mention-ref.test.ts` + +**Interfaces:** + +- Consumes: nothing (pure string module). +- Produces: + - `const SESSION_MENTION_PREFIX = 'session:'` + - `interface SessionRef { id?: string; title?: string }` + - `function parseSessionRef(pathName: string): SessionRef | null` — returns `null` when `pathName` does not start with `session:`; otherwise `{ id }` if the remainder is a valid UUID, else `{ title }`. + - `function buildSessionRef(idOrTitle: string): string` — returns `@session:` (no leading `@`? see below). + - `function isSessionId(value: string): boolean` — UUID v4 shape check. + +Note on `@`: mirror `extension-mention-ref.ts` — `buildExtensionRef` returns the value WITHOUT leading `@` (the `@` is already in the buffer). Match that: `buildSessionRef('abc')` → `'session:abc'`. + +- [ ] **Step 1: Write the failing test** + +```ts +// packages/cli/src/ui/hooks/session-mention-ref.test.ts +import { describe, it, expect } from 'vitest'; +import { + parseSessionRef, + buildSessionRef, + isSessionId, + SESSION_MENTION_PREFIX, +} from './session-mention-ref.js'; + +const UUID = '3f2504e0-4f89-41d3-9a0c-0305e82c3301'; + +describe('sessionMentionRef', () => { + it('returns null for non-session tokens', () => { + expect(parseSessionRef('file.txt')).toBeNull(); + expect(parseSessionRef('ext:foo')).toBeNull(); + }); + + it('parses a UUID remainder as an id', () => { + expect(parseSessionRef(`${SESSION_MENTION_PREFIX}${UUID}`)).toEqual({ + id: UUID, + }); + }); + + it('parses a non-UUID remainder as a title', () => { + expect(parseSessionRef('session:Fix auth bug')).toEqual({ + title: 'Fix auth bug', + }); + }); + + it('treats an empty remainder as null (lone prefix)', () => { + expect(parseSessionRef('session:')).toBeNull(); + }); + + it('builds a ref without a leading @', () => { + expect(buildSessionRef(UUID)).toBe(`session:${UUID}`); + }); + + it('recognizes UUIDs', () => { + expect(isSessionId(UUID)).toBe(true); + expect(isSessionId('not-a-uuid')).toBe(false); + }); +}); +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `npx vitest run packages/cli/src/ui/hooks/session-mention-ref.test.ts` +Expected: FAIL — `Cannot find module './session-mention-ref.js'`. + +- [ ] **Step 3: Write minimal implementation** + +```ts +// packages/cli/src/ui/hooks/session-mention-ref.ts +export const SESSION_MENTION_PREFIX = 'session:'; + +const UUID_RE = + /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i; + +export interface SessionRef { + id?: string; + title?: string; +} + +export function isSessionId(value: string): boolean { + return UUID_RE.test(value.trim()); +} + +export function parseSessionRef(pathName: string): SessionRef | null { + if (!pathName.startsWith(SESSION_MENTION_PREFIX)) return null; + const remainder = pathName.slice(SESSION_MENTION_PREFIX.length).trim(); + if (remainder.length === 0) return null; + return isSessionId(remainder) ? { id: remainder } : { title: remainder }; +} + +export function buildSessionRef(idOrTitle: string): string { + return `${SESSION_MENTION_PREFIX}${idOrTitle}`; +} +``` + +- [ ] **Step 4: Run test to verify it passes** + +Run: `npx vitest run packages/cli/src/ui/hooks/session-mention-ref.test.ts` +Expected: PASS (6 tests). + +- [ ] **Step 5: Commit** + +```bash +git add packages/cli/src/ui/hooks/session-mention-ref.ts packages/cli/src/ui/hooks/session-mention-ref.test.ts +git commit -m "feat(cli): add @session: mention ref parser" +``` + +--- + +### Task 2: `SessionReferenceService` — load + slim + budget-trim + +**Files:** + +- Create: `packages/core/src/services/session-reference-service.ts` +- Test: `packages/core/src/services/session-reference-service.test.ts` +- Modify (export barrel): `packages/core/src/index.ts` (add `export * from './services/session-reference-service.js';` alongside existing service exports) + +**Interfaces:** + +- Consumes: `SessionService.loadSession(id): Promise` (existing); `ResumedSessionData.conversation.messages: ChatRecord[]`; `ChatRecord` fields `type`, `message?: Content`, `toolCallResult?`; `estimateContentTokens(contents: Content[]): number` from `./tokenEstimation.js`. +- Produces: + - `const SESSION_REF_TOKEN_BUDGET = 8000` + - `interface SlimmedSessionReference { text: string; meta: { sessionId: string; title: string; messageCount: number; approxTokens: number }; truncated: boolean }` + - `class SessionReferenceService { constructor(cwd: string); resolve(sessionId: string, opts?: { budgetTokens?: number; title?: string }): Promise }` + +Design notes for the implementer: + +- Do NOT reuse `filterToDialog` (it is private in `sessionTitle.ts` AND drops tool calls, which we need to summarize). Walk `messages` directly. +- Per record: `type === 'user'` → collect text parts prefixed `User: `; `type === 'assistant'` → collect text parts prefixed `Assistant: ` (skip `thought` parts); records that are tool calls (record has `toolCallResult`, or `message.parts` contains a `functionCall`/`functionResponse`) → emit one line `[tool: ]`. Ignore `system` records. +- Title resolution for the `{ title }` case is done by the CALLER (atCommandProcessor) via `SessionService.findSessionsByTitle` before calling `resolve`; `resolve` itself takes an `{ id }`. Keep `resolve` id-only to stay pure/testable. (Update the Produces signature accordingly: `resolve(id: string, opts?)`.) The ambiguous/not-found title handling lives in Task 3. +- Budget trim: build an array of per-turn strings, estimate tokens of the joined text via `estimateContentTokens([{ role: 'user', parts: [{ text }] }])`; while over budget, drop the OLDEST line and re-check; if any dropped, prepend `[earlier turns omitted]\n` and set `truncated: true`. + +- [ ] **Step 1: Write the failing test** + +```ts +// packages/core/src/services/session-reference-service.test.ts +import { describe, it, expect, vi } from 'vitest'; +import { SessionReferenceService } from './session-reference-service.js'; +import type { ResumedSessionData } from './sessionService.js'; + +function fakeResumed(messages: unknown[]): ResumedSessionData { + return { + conversation: { + sessionId: 's1', + projectHash: 'h', + startTime: '', + lastUpdated: '', + messages: messages as never, + }, + filePath: '/tmp/s1.jsonl', + lastCompletedUuid: null, + } as ResumedSessionData; +} + +function makeSvc(resumed: ResumedSessionData | undefined) { + const svc = new SessionReferenceService('/proj'); + // Inject a stub SessionService.loadSession + (svc as unknown as { loadSession: () => Promise }).loadSession = vi + .fn() + .mockResolvedValue(resumed); + return svc; +} + +describe('SessionReferenceService', () => { + it('returns notFound when session is missing', async () => { + const svc = makeSvc(undefined); + expect(await svc.resolve('missing')).toEqual({ notFound: true }); + }); + + it('keeps user + assistant text and drops thoughts', async () => { + const svc = makeSvc( + fakeResumed([ + { type: 'user', message: { role: 'user', parts: [{ text: 'hi' }] } }, + { + type: 'assistant', + message: { + role: 'model', + parts: [{ thought: true, text: 'reason' }, { text: 'hello' }], + }, + }, + ]), + ); + const res = await svc.resolve('s1'); + if ('notFound' in res) throw new Error('unexpected'); + expect(res.text).toContain('User: hi'); + expect(res.text).toContain('Assistant: hello'); + expect(res.text).not.toContain('reason'); + }); + + it('collapses tool calls to one-line summaries without result bodies', async () => { + const svc = makeSvc( + fakeResumed([ + { + type: 'tool_result', + toolCallResult: { displayName: 'Read File', status: 'success' }, + message: { + role: 'user', + parts: [ + { + functionResponse: { name: 'read', response: { huge: 'BODY' } }, + }, + ], + }, + }, + ]), + ); + const res = await svc.resolve('s1'); + if ('notFound' in res) throw new Error('unexpected'); + expect(res.text).toContain('[tool: Read File — success]'); + expect(res.text).not.toContain('BODY'); + }); + +> **Implementation note:** `ToolCallResponseInfo` has no `displayName` field in production. The shipped `session-reference-service.ts` derives tool names from `functionResponse` parts instead. + + it('tail-trims to budget and marks truncated', async () => { + const many = Array.from({ length: 50 }, (_, i) => ({ + type: 'user', + message: { + role: 'user', + parts: [{ text: `turn ${i} ` + 'x'.repeat(400) }], + }, + })); + const svc = makeSvc(fakeResumed(many)); + const res = await svc.resolve('s1', { budgetTokens: 200 }); + if ('notFound' in res) throw new Error('unexpected'); + expect(res.truncated).toBe(true); + expect(res.text).toContain('[earlier turns omitted]'); + expect(res.text).toContain('turn 49'); // newest retained + expect(res.text).not.toContain('turn 0'); // oldest dropped + }); +}); +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `npx vitest run packages/core/src/services/session-reference-service.test.ts` +Expected: FAIL — `Cannot find module './session-reference-service.js'`. + +- [ ] **Step 3: Write minimal implementation** + +```ts +// packages/core/src/services/session-reference-service.ts +import type { Content, Part } from '@google/genai'; +import { SessionService } from './sessionService.js'; +import type { ChatRecord } from './chatRecordingService.js'; +import { estimateContentTokens } from './tokenEstimation.js'; + +export const SESSION_REF_TOKEN_BUDGET = 8000; + +export interface SlimmedSessionReference { + text: string; + meta: { + sessionId: string; + title: string; + messageCount: number; + approxTokens: number; + }; + truncated: boolean; +} + +export class SessionReferenceService { + private readonly sessionService: SessionService; + constructor(private readonly cwd: string) { + this.sessionService = new SessionService(cwd); + } + + // Indirection kept as an instance method so tests can stub it. + protected loadSession(sessionId: string) { + return this.sessionService.loadSession(sessionId); + } + + async resolve( + sessionId: string, + opts: { budgetTokens?: number } = {}, + ): Promise { + const resumed = await this.loadSession(sessionId); + if (!resumed) return { notFound: true }; + + const records = resumed.conversation.messages ?? []; + const lines = this.recordsToLines(records); + const budget = opts.budgetTokens ?? SESSION_REF_TOKEN_BUDGET; + + let kept = [...lines]; + let truncated = false; + while (kept.length > 0 && this.estimate(kept) > budget) { + kept.shift(); // drop oldest first (tail-retention) + truncated = true; + } + const body = + (truncated ? '[earlier turns omitted]\n' : '') + kept.join('\n'); + const title = sessionId; // TODO: derive friendlier title from first user message + const text = + body.trim().length === 0 + ? `--- Referenced session "${title}" (slimmed, read-only) ---\n(no textual content)` + : `--- Referenced session "${title}" (slimmed, read-only) ---\n${body}`; + + return { + text, + meta: { + sessionId, + title, + messageCount: records.length, + approxTokens: this.estimate(kept), + }, + truncated, + }; + } + + private estimate(lines: string[]): number { + const contents: Content[] = [ + { role: 'user', parts: [{ text: lines.join('\n') }] }, + ]; + return estimateContentTokens(contents); + } + + private recordsToLines(records: ChatRecord[]): string[] { + const out: string[] = []; + for (const rec of records) { + if (rec.toolCallResult || this.hasFunctionPart(rec.message)) { + const name = + rec.toolCallResult?.displayName ?? + this.functionName(rec.message) ?? + 'tool'; + const status = rec.toolCallResult?.status ?? 'ok'; + out.push(`[tool: ${name} — ${status}]`); + } + // Emit user/assistant text separately (before the tool summary block) + // to avoid dropping assistant reasoning on tool-calling turns. + if (rec.type === 'user') { + const text = this.visibleText(rec.message); + if (text) out.push(`User: ${text}`); + } else if (rec.type === 'assistant') { + const text = this.visibleText(rec.message); + if (text) out.push(`Assistant: ${text}`); + } + // system records ignored + } + return out; + } + // NOTE: The shipped implementation (session-reference-service.ts) uses a + // two-pass approach instead — emit visible text first (user/assistant), + // then tool summaries from functionResponse parts in a separate pass. + // The `continue` above would silently drop assistant reasoning on turns + // that also call a tool; do NOT copy this version verbatim. + + private visibleText(message?: Content): string { + if (!message?.parts) return ''; + return message.parts + .filter((p: Part) => !(p as { thought?: boolean }).thought && p.text) + .map((p: Part) => p.text) + .join('') + .trim(); + } + + private hasFunctionPart(message?: Content): boolean { + return ( + message?.parts?.some( + (p: Part) => + (p as { functionCall?: unknown }).functionCall || + (p as { functionResponse?: unknown }).functionResponse, + ) ?? false + ); + } + +> **Implementation note:** The shipped implementation matches only `functionResponse` parts (not `functionCall`), because call-side records produce duplicate always-'ok' summaries before the result arrives. + + private functionName(message?: Content): string | undefined { + const p = message?.parts?.find( + (x: Part) => + (x as { functionCall?: { name?: string } }).functionCall || + (x as { functionResponse?: { name?: string } }).functionResponse, + ); + return ( + (p as { functionCall?: { name?: string } })?.functionCall?.name ?? + (p as { functionResponse?: { name?: string } })?.functionResponse?.name + ); + } +} +``` + +- [ ] **Step 4: Run test to verify it passes** + +Run: `npx vitest run packages/core/src/services/session-reference-service.test.ts` +Expected: PASS (4 tests). + +- [ ] **Step 5: Add barrel export + typecheck** + +Add to `packages/core/src/index.ts` (near other `./services/*` exports): + +```ts +export * from './services/session-reference-service.js'; +``` + +Run: `npx tsc --noEmit -p packages/core/tsconfig.json` +Expected: no errors in the new file. + +- [ ] **Step 6: Commit** + +```bash +git add packages/core/src/services/session-reference-service.ts packages/core/src/services/session-reference-service.test.ts packages/core/src/index.ts +git commit -m "feat(core): add SessionReferenceService for slimmed session injection" +``` + +--- + +### Task 3: Route `@session:` through `atCommandProcessor` and inject + +**Files:** + +- Modify: `packages/cli/src/ui/hooks/atCommandProcessor.ts` (add routing branch after the MCP-server branch near line 281, before the filesystem containment check near line 320; inject into `scopedMentionParts` assembled near line 611) +- Test: `packages/cli/src/ui/hooks/atCommandProcessor.session.test.ts` + +**Interfaces:** + +- Consumes: `parseSessionRef`, `SESSION_MENTION_PREFIX` (Task 1); `SessionReferenceService` (Task 2); existing `SessionService.findSessionsByTitle(title): Promise`. +- Produces: injected `{ text }` part appended to the scoped-mention bucket; a "Referenced session" display card; literal-text fallback for unresolved refs. + +Behavior: + +1. When a parsed token yields `parseSessionRef(pathName)` non-null: resolve the id. + - `{ id }` → use directly. + - `{ title }` → `findSessionsByTitle(title)`; 0 matches → not-found; >1 → ambiguous; 1 → use its `sessionId`. +2. Call `new SessionReferenceService(config.getWorkingDir()).resolve(id)`. + - `{ notFound: true }` OR ambiguous OR 0-match → leave the `@session:…` token as literal text in the prompt and push a warning note (mirror how unresolved mentions are surfaced elsewhere in this file); do NOT throw. + - success → push `{ text: result.text }` into the scoped-mention parts and add a display card titled `Referenced session` (mirror existing `Activate Extension` card construction in this file). + - exception from `resolve()` (e.g. corrupt session file, I/O error) → leave the `@session:…` token as literal text and push a warning note; do NOT throw. + +- [ ] **Step 1: Write the failing test** + +```ts +// packages/cli/src/ui/hooks/atCommandProcessor.session.test.ts +import { describe, it, expect, vi } from 'vitest'; +// NOTE to implementer: import handleAtCommand and mirror the mock setup from +// the existing atCommandProcessor.test.ts in this directory (Config, workspace, +// addItem). This test focuses only on the @session: branch. +import { handleAtCommand } from './atCommandProcessor.js'; + +vi.mock('@qwen-code/qwen-code-core', async (orig) => { + const actual = (await orig()) as Record; + return { + ...actual, + SessionReferenceService: class { + resolve = vi.fn().mockResolvedValue({ + text: '--- Referenced session "s1" (slimmed, read-only) ---\nUser: hi', + meta: { + sessionId: 's1', + title: 's1', + messageCount: 1, + approxTokens: 5, + }, + truncated: false, + }); + }, + }; +}); + +describe('atCommandProcessor @session:', () => { + it('injects slimmed session text as a part', async () => { + // Arrange config/workspace/addItem mocks per existing test harness, then: + const result = await handleAtCommand({ + query: 'see @session:3f2504e0-4f89-41d3-9a0c-0305e82c3301 please', + // ...harness args... + } as never); + const joined = JSON.stringify(result.processedQuery); + expect(joined).toContain('Referenced session'); + expect(joined).toContain('User: hi'); + }); +}); +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `npx vitest run packages/cli/src/ui/hooks/atCommandProcessor.session.test.ts` +Expected: FAIL — assertion fails (session text not injected) because the branch does not exist yet. + +- [ ] **Step 3: Implement the routing branch** + +In `atCommandProcessor.ts`, add imports at the top: + +```ts +import { parseSessionRef } from './session-mention-ref.js'; +import { SessionReferenceService } from '@qwen-code/qwen-code-core'; +``` + +Add this branch immediately after the `parseMcpServerRef` handling (~line 281) and before the filesystem `isPathWithinWorkspace` check (~line 320): + +```ts +const sessionRef = parseSessionRef(pathName); +if (sessionRef) { + let sessionId = sessionRef.id; + if (!sessionId && sessionRef.title) { + try { + const matches = await new SessionService( + config.getWorkingDir(), + ).findSessionsByTitle(sessionRef.title); + if (matches.length === 1) { + sessionId = matches[0].sessionId; + } else { + // 0 or >1: leave literal, warn, skip injection + addItem( + { + type: MessageType.INFO, + text: + matches.length === 0 + ? `No session matches "@session:${sessionRef.title}".` + : `"@session:${sessionRef.title}" is ambiguous (${matches.length} matches); use the picker.`, + }, + userMessageTimestamp, + ); + continue; // token already retained as literal text + } + } catch { + // emit error card and continue + } + +> **Implementation note:** The shipped code emits a proper error card (`addItem` with `MessageType.INFO`) inside this `catch` before `continue`, rather than falling through to `resolve(sessionId!)` with `sessionId` still `undefined`. + } + try { + const ref = await new SessionReferenceService( + config.getWorkingDir(), + ).resolve(sessionId!); + if ('notFound' in ref) { + addItem( + { type: MessageType.INFO, text: `Session "${sessionId}" not found.` }, + userMessageTimestamp, + ); + continue; + } + scopedMentionEntries.push({ + part: { text: ref.text }, + // mirror the card shape used by the extension/MCP-server branches: + card: { title: 'Referenced session', detail: ref.meta.title }, + }); + continue; + } catch { + addItem( + { + type: MessageType.INFO, + text: `Failed to load session "${sessionId}".`, + }, + userMessageTimestamp, + ); + continue; + } +} +``` + +(Implementer: match the exact `scopedMentionEntries` element shape and `addItem`/`MessageType` imports already used in this file; the block above shows intent and names.) + +- [ ] **Step 4: Run test to verify it passes** + +Run: `npx vitest run packages/cli/src/ui/hooks/atCommandProcessor.session.test.ts` +Expected: PASS. + +- [ ] **Step 5: Regression + typecheck** + +Run: `npx vitest run packages/cli/src/ui/hooks/atCommandProcessor.test.ts` +Expected: PASS (existing tests unaffected). +Run: `npx tsc --noEmit -p packages/cli/tsconfig.json` +Expected: no new errors. + +- [ ] **Step 6: Commit** + +```bash +git add packages/cli/src/ui/hooks/atCommandProcessor.ts packages/cli/src/ui/hooks/atCommandProcessor.session.test.ts +git commit -m "feat(cli): inject slimmed prior-session context on @session: mention" +``` + +--- + +### Task 4: `category` field + session-suggestion producer + +**Files:** + +- Modify: `packages/cli/src/ui/components/SuggestionsDisplay.tsx:19-45` (add `category` to `Suggestion`, add `SuggestionCategory` type) +- Create: `packages/cli/src/ui/hooks/session-completion.ts` (producer, mirrors `extension-mention-ref.ts`) +- Modify: `packages/cli/src/ui/hooks/useAtCompletion.ts` (call producer; tag file results; merge near lines 439/486/493) +- Test: `packages/cli/src/ui/hooks/session-completion.test.ts` + +**Interfaces:** + +- Consumes: `SessionService.listSessions({ size }): Promise<{ items: SessionListItem[]; hasMore }>`; `SessionListItem` fields `sessionId`, `customTitle?`, `prompt`, `mtime`; `buildSessionRef` (Task 1). +- Produces: + - `type SuggestionCategory = 'file' | 'session' | 'mcp' | 'extension'` + - `Suggestion.category?: SuggestionCategory` + - `async function getSessionSuggestions(cwd: string, pattern: string): Promise` + +- [ ] **Step 1: Add the type field (no test — type-only), then write the producer test** + +Edit `SuggestionsDisplay.tsx` — add above `export interface Suggestion`: + +```ts +export type SuggestionCategory = 'file' | 'session' | 'mcp' | 'extension'; +``` + +and inside `Suggestion`: + +```ts + /** Grouping category for the tabbed completion UI. Defaults to 'file'. */ + category?: SuggestionCategory; +``` + +Producer test: + +```ts +// packages/cli/src/ui/hooks/session-completion.test.ts +import { describe, it, expect, vi } from 'vitest'; + +vi.mock('@qwen-code/qwen-code-core', async (orig) => { + const actual = (await orig()) as Record; + return { + ...actual, + SessionService: class { + listSessions = vi.fn().mockResolvedValue({ + items: [ + { + sessionId: 'id-1', + customTitle: 'Fix auth bug', + prompt: 'fix auth', + mtime: 2, + }, + { + sessionId: 'id-2', + customTitle: undefined, + prompt: 'add tests', + mtime: 1, + }, + ], + hasMore: false, + }); + }, + }; +}); + +import { getSessionSuggestions } from './session-completion.js'; + +describe('getSessionSuggestions', () => { + it('maps sessions to category:session suggestions with @session: values', async () => { + const out = await getSessionSuggestions('/proj', ''); + expect(out).toHaveLength(2); + expect(out[0]).toMatchObject({ + label: 'Fix auth bug', + value: 'session:id-1', + category: 'session', + }); + // falls back to first prompt when no custom title + expect(out[1].label).toBe('add tests'); + }); + + it('filters by pattern against title and prompt', async () => { + const out = await getSessionSuggestions('/proj', 'auth'); + expect(out).toHaveLength(1); + expect(out[0].value).toBe('session:id-1'); + }); +}); +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `npx vitest run packages/cli/src/ui/hooks/session-completion.test.ts` +Expected: FAIL — `Cannot find module './session-completion.js'`. + +- [ ] **Step 3: Implement the producer** + +```ts +// packages/cli/src/ui/hooks/session-completion.ts +import { SessionService } from '@qwen-code/qwen-code-core'; +import type { Suggestion } from '../components/SuggestionsDisplay.js'; +import { + buildSessionRef, + SESSION_MENTION_PREFIX, +} from './session-mention-ref.js'; + +const MAX_SESSION_SUGGESTIONS = 20; + +export async function getSessionSuggestions( + cwd: string, + pattern: string, +): Promise { + let items; + try { + const res = await new SessionService(cwd).listSessions({ + size: MAX_SESSION_SUGGESTIONS, + }); + items = res.items; + } catch { + return []; // I/O failure → session tab simply empty + } + const stripped = pattern.startsWith(SESSION_MENTION_PREFIX) + ? pattern.slice(SESSION_MENTION_PREFIX.length) + : pattern.toLowerCase() === 'session' + ? '' + : pattern; + const needle = stripped.trim().toLowerCase(); + return items + .map((s) => { + const label = s.customTitle?.trim() || s.prompt || s.sessionId; + return { + label, + value: buildSessionRef(s.sessionId), + description: s.customTitle ? s.prompt : undefined, + sourceBadge: 'Session', + category: 'session' as const, + } satisfies Suggestion; + }) + .filter((sug) => + needle.length === 0 + ? true + : `${sug.label} ${sug.description ?? ''}` + .toLowerCase() + .includes(needle), + ); +} +``` + +- [ ] **Step 4: Run test to verify it passes** + +Run: `npx vitest run packages/cli/src/ui/hooks/session-completion.test.ts` +Expected: PASS (2 tests). + +- [ ] **Step 5: Wire into `useAtCompletion.ts`** + +- Add import: `import { getSessionSuggestions } from './session-completion.js';` +- Tag file results with `category: 'file'` at the `fileSuggestions` map (~line 486): + +```ts +const fileSuggestions = results.map((p) => ({ + label: p, + value: escapePath(p), + isDirectory: p.endsWith('/'), + category: 'file' as const, +})); +``` + +- Tag extension suggestions `category: 'extension'` and MCP suggestions `category: 'mcp'` at their producers (in `useAtCompletion.ts` for MCP; add `category: 'extension'` inside `getExtensionSuggestions` in `extension-mention-ref.ts`). +- Fetch session suggestions and prepend them to the merged list. Where `mcpSuggestions` is assembled (~line 439) and merged with files (~line 493), add sessions so bare `@` shows them: + +```ts +const sessionSuggestions = await getSessionSuggestions( + config?.getWorkingDir() ?? process.cwd(), + state.pattern, +); +// merge order: extensions, sessions, mcp, then files +dispatch({ + type: 'SEARCH_SUCCESS', + payload: [...mcpSuggestions, ...sessionSuggestions, ...fileSuggestions], +}); +``` + +(Implementer: place the `await getSessionSuggestions` alongside the existing async file search; keep it inside the same abortable path so a new keystroke cancels it. Sessions are shown on empty pattern like extensions.) + +- [ ] **Step 6: Typecheck + existing completion tests** + +Run: `npx tsc --noEmit -p packages/cli/tsconfig.json` +Run: `npx vitest run packages/cli/src/ui/hooks/useAtCompletion.test.ts` +Expected: PASS (existing) + no type errors. + +- [ ] **Step 7: Commit** + +```bash +git add packages/cli/src/ui/components/SuggestionsDisplay.tsx packages/cli/src/ui/hooks/session-completion.ts packages/cli/src/ui/hooks/session-completion.test.ts packages/cli/src/ui/hooks/useAtCompletion.ts packages/cli/src/ui/hooks/extension-mention-ref.ts +git commit -m "feat(cli): surface prior sessions as @ completion suggestions" +``` + +--- + +### Task 5: Tab bar + category filtering in `SuggestionsDisplay` + +**Files:** + +- Modify: `packages/cli/src/ui/components/SuggestionsDisplay.tsx` (add `activeCategory` prop, tab bar, row filtering) +- Test: `packages/cli/src/ui/components/SuggestionsDisplay.test.tsx` + +**Interfaces:** + +- Consumes: `Suggestion.category` (Task 4); `activeIndex`, `scrollOffset` (existing props). +- Produces: new props `activeCategory?: SuggestionCategory | 'all'`, `availableCategories?: Array`. When `activeCategory` is set and not `'all'`, only rows whose `category === activeCategory` render. A tab bar renders when `availableCategories.length > 2` (i.e., more than just `all` + one category); otherwise hidden (no regression for plain file completion). + +- [ ] **Step 1: Write the failing test** + +```tsx +// packages/cli/src/ui/components/SuggestionsDisplay.test.tsx +import { describe, it, expect } from 'vitest'; +import { render } from 'ink-testing-library'; +import { SuggestionsDisplay } from './SuggestionsDisplay.js'; + +const suggestions = [ + { label: 'a.ts', value: 'a.ts', category: 'file' as const }, + { label: 'Fix bug', value: 'session:id-1', category: 'session' as const }, +]; + +describe('SuggestionsDisplay tabs', () => { + it('shows a tab bar when multiple categories are present', () => { + const { lastFrame } = render( + , + ); + expect(lastFrame()).toContain('Files'); + expect(lastFrame()).toContain('Sessions'); + }); + + it('filters rows to the active category', () => { + const { lastFrame } = render( + , + ); + expect(lastFrame()).toContain('Fix bug'); + expect(lastFrame()).not.toContain('a.ts'); + }); + + it('hides the tab bar for single-category (file-only) completion', () => { + const { lastFrame } = render( + , + ); + expect(lastFrame()).not.toContain('Sessions'); + }); +}); +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `npx vitest run packages/cli/src/ui/components/SuggestionsDisplay.test.tsx` +Expected: FAIL — tab labels absent / props unknown. + +- [ ] **Step 3: Implement tab bar + filter** + +- Extend `SuggestionsDisplayProps` with: + +```ts + activeCategory?: SuggestionCategory | 'all'; + availableCategories?: Array; +``` + +- Add a label map: + +```ts +const CATEGORY_LABEL: Record = { + all: 'All', + file: 'Files', + session: 'Sessions', + mcp: 'MCP', + extension: 'Extensions', +}; +``` + +- Before slicing/rendering rows, filter: + +```ts +const visible = + !activeCategory || activeCategory === 'all' + ? suggestions + : suggestions.filter((s) => (s.category ?? 'file') === activeCategory); +``` + +Use `visible` in place of `suggestions` for the existing `scrollOffset`/`MAX_SUGGESTIONS_TO_SHOW` slice. + +> **Implementation note:** The shipped implementation consolidates category filtering inside `useCompletion` (filtering `rawSuggestions` into the exposed `suggestions` memo) so that `activeSuggestionIndex` always addresses the same visible list. The split described here between Tasks 5 and 6 would misalign the highlight index. + +- Render a tab bar (mirror `StatsTabs` in `StatsDialog.tsx`) above the list, only when `(availableCategories?.length ?? 0) > 2`: + +```tsx +{ + (availableCategories?.length ?? 0) > 2 && ( + + {availableCategories!.map((cat, i) => { + const active = cat === activeCategory; + return ( + + + {` ${CATEGORY_LABEL[cat]} `} + + + ); + })} + + (←/→ to switch) + + + ); +} +``` + +- [ ] **Step 4: Run test to verify it passes** + +Run: `npx vitest run packages/cli/src/ui/components/SuggestionsDisplay.test.tsx` +Expected: PASS (3 tests). + +- [ ] **Step 5: Commit** + +```bash +git add packages/cli/src/ui/components/SuggestionsDisplay.tsx packages/cli/src/ui/components/SuggestionsDisplay.test.tsx +git commit -m "feat(cli): tabbed category layout for @ completion dropdown" +``` + +--- + +### Task 6: `activeCategory` state + `←/→` tab-switch keybinding + +**Files:** + +- Modify: `packages/cli/src/ui/hooks/useCompletion.ts` (add `activeCategory`, `availableCategories`, `switchCategory(direction)`, reset index on switch) +- Modify: `packages/cli/src/ui/keyMatchers.ts` (or the keybindings command file) — add `Command.COMPLETION_TAB_LEFT` / `COMPLETION_TAB_RIGHT` bound to `←`/`→` while suggestions are shown +- Modify: `packages/cli/src/ui/components/InputPrompt.tsx` (~lines 1386–1448) — handle the new commands; pass `activeCategory`/`availableCategories` into `suggestionDisplayProps` (~line 1948) +- Test: `packages/cli/src/ui/hooks/useCompletion.test.ts` (extend existing) + +**Interfaces:** + +- Consumes: `Suggestion.category` (Task 4); `SuggestionCategory` (Task 4). +- Produces: `useCompletion` returns `activeCategory: SuggestionCategory | 'all'`, `availableCategories: Array`, `switchCategory(direction: 1 | -1): void`. + +Behavior: `availableCategories` = `['all', ...distinct categories present in suggestions, in fixed order file/session/mcp/extension]`. `switchCategory` cycles within `availableCategories`, wraps, and resets `activeSuggestionIndex = 0` + `visibleStartIndex = 0`. When suggestions change and the current `activeCategory` no longer exists, reset to `'all'`. + +- [ ] **Step 1: Write the failing test** + +```ts +// add to packages/cli/src/ui/hooks/useCompletion.test.ts +import { renderHook, act } from '@testing-library/react'; +import { useCompletion } from './useCompletion.js'; + +it('derives availableCategories and cycles with switchCategory', () => { + const { result } = renderHook(() => useCompletion()); + act(() => { + result.current.setSuggestions([ + { label: 'a.ts', value: 'a.ts', category: 'file' }, + { label: 'S', value: 'session:1', category: 'session' }, + ]); + }); + expect(result.current.availableCategories).toEqual([ + 'all', + 'file', + 'session', + ]); + expect(result.current.activeCategory).toBe('all'); + act(() => result.current.switchCategory(1)); + expect(result.current.activeCategory).toBe('file'); + expect(result.current.activeSuggestionIndex).toBe(0); +}); +``` + +(Implementer: match the actual `useCompletion` setter API — if it exposes `setSuggestions`/a reducer, adapt the arrange step accordingly.) + +- [ ] **Step 2: Run test to verify it fails** + +Run: `npx vitest run packages/cli/src/ui/hooks/useCompletion.test.ts -t 'availableCategories'` +Expected: FAIL — `availableCategories`/`switchCategory` undefined. + +- [ ] **Step 3: Implement state in `useCompletion.ts`** + +Add: + +```ts +const CATEGORY_ORDER: SuggestionCategory[] = [ + 'file', + 'session', + 'mcp', + 'extension', +]; + +const availableCategories = useMemo>(() => { + const present = new Set(suggestions.map((s) => s.category ?? 'file')); + const ordered = CATEGORY_ORDER.filter((c) => present.has(c)); + return ordered.length > 1 ? ['all', ...ordered] : ['all']; +}, [suggestions]); + +const [activeCategory, setActiveCategory] = useState< + SuggestionCategory | 'all' +>('all'); + +useEffect(() => { + if (!availableCategories.includes(activeCategory)) setActiveCategory('all'); +}, [availableCategories, activeCategory]); + +const switchCategory = useCallback( + (direction: 1 | -1) => { + setActiveCategory((cur) => { + const idx = availableCategories.indexOf(cur); + const next = + (idx + direction + availableCategories.length) % + availableCategories.length; + return availableCategories[next]; + }); + setActiveSuggestionIndex(0); + setVisibleStartIndex(0); + }, + [availableCategories], +); +``` + +> **Implementation note:** The shipped code adds `if (idx === -1) return 'all';` before the modular arithmetic to guard against a stale category during React state batching. + +Return `activeCategory`, `availableCategories`, `switchCategory` from the hook. + +- [ ] **Step 4: Add keybindings + InputPrompt wiring** + +- In the keybindings command enum/file add `COMPLETION_TAB_LEFT` (`left`/`←`) and `COMPLETION_TAB_RIGHT` (`right`/`→`). +- In `InputPrompt.tsx` inside the `showCompletionSuggestions` block (~line 1386), BEFORE the `ACCEPT_SUGGESTION` handling: + +```ts +if (keyMatchers[Command.COMPLETION_TAB_RIGHT](key)) { + completion.switchCategory(1); + return true; +} +if (keyMatchers[Command.COMPLETION_TAB_LEFT](key)) { + completion.switchCategory(-1); + return true; +} +``` + +- In `suggestionDisplayProps` (~line 1948) add: + +```ts +activeCategory: completion.activeCategory, +availableCategories: completion.availableCategories, +``` + +Guard: only consume `←/→` when `availableCategories.length > 2`, so left/right cursor movement in the buffer is unaffected during plain file completion. (Fold this guard into the two `if` blocks above.) + +- [ ] **Step 5: Run tests + typecheck** + +Run: `npx vitest run packages/cli/src/ui/hooks/useCompletion.test.ts` +Run: `npx tsc --noEmit -p packages/cli/tsconfig.json` +Expected: PASS + no type errors. + +- [ ] **Step 6: Commit** + +```bash +git add packages/cli/src/ui/hooks/useCompletion.ts packages/cli/src/ui/keyMatchers.ts packages/cli/src/ui/components/InputPrompt.tsx packages/cli/src/ui/hooks/useCompletion.test.ts +git commit -m "feat(cli): ←/→ tab switching for @ completion categories" +``` + +--- + +### Task 7: End-to-end verification + full test/lint gate + +**Files:** none (verification only) + +- [ ] **Step 1: Full core + cli test run** + +Run: `npm run build && npx vitest run packages/core packages/cli` +Expected: all pass (including the new suites). + +- [ ] **Step 2: Lint + typecheck (align with CI)** + +Run: `npm run lint && npm run typecheck` +Expected: clean. + +- [ ] **Step 3: Manual TUI smoke (use the run skill or a real terminal)** + +1. Start the CLI in a project that has ≥2 prior sessions. +2. Type `@` → confirm a tab bar appears with `All / Files / Sessions`. +3. Press `→` to reach `Sessions`, `↑/↓` to select one, `Enter`/`Tab` to accept → buffer shows `@session:`. +4. Submit a prompt → confirm a `Referenced session` card renders and the slimmed block reaches the model (labeled `--- Referenced session … ---`, no tool result bodies). +5. Type `@session:` and submit → confirm the "not found" info line and literal-text fallback (no crash). + +- [ ] **Step 4: Update the design spec status** + +Edit `docs/superpowers/specs/2026-07-17-at-session-reference-design.md`: change `Status: Proposed` → `Status: Implemented`. + +- [ ] **Step 5: Commit** + +```bash +git add docs/superpowers/specs/2026-07-17-at-session-reference-design.md +git commit -m "docs: mark @ session reference design as implemented" +``` + +--- + +## Self-Review + +**Spec coverage:** Goal-1 (reference sessions) → Tasks 1–4; Goal-2 (tabbed UI) → Tasks 4–6. Each locked decision maps to a task: no-LLM slimming → Task 2 (Global Constraints forbid `runSideQuery`); tool one-liners → Task 2 recordsToLines; current-project scope → Tasks 2/4 via SessionService; bare-`@` sessions → Task 4 merge; tab switch → Tasks 5/6; fixed budget tail-retention → Task 2 budget loop. Error-handling table → Task 3 (not-found/ambiguous/empty) + Task 4 (listSessions failure) + Task 5 (single-category hides tabs). Testing section → per-task tests + Task 7. + +**Placeholder scan:** No TBD/TODO. Integration edits (Tasks 3–6) show concrete code with a note to match exact local names where the surrounding file's shapes (card element, reducer setters, keyMatchers file) can't be quoted verbatim without reading them at execution time — acceptable because names are specified. + +**Type consistency:** `SessionRef {id?,title?}` (Task 1) consumed by Task 3; `SlimmedSessionReference` / `resolve(sessionId, opts)` (Task 2) consumed by Task 3; `SuggestionCategory` / `Suggestion.category` (Task 4) consumed by Tasks 5/6; `getSessionSuggestions(cwd, pattern)` (Task 4) consumed by useAtCompletion; `switchCategory(1|-1)`, `activeCategory`, `availableCategories` (Task 6) consumed by InputPrompt + SuggestionsDisplay props (Task 5). Names align across tasks. + +**Known execution-time adaptations (flagged, not placeholders):** (a) exact `scopedMentionEntries` element/card shape in `atCommandProcessor.ts`; (b) `useCompletion` setter API for the test arrange step; (c) the keybindings command file's exact location/enum. Each is called out inline in the owning task. diff --git a/docs/superpowers/specs/2026-07-17-at-session-reference-design.md b/docs/superpowers/specs/2026-07-17-at-session-reference-design.md new file mode 100644 index 00000000000..56f0d586bb9 --- /dev/null +++ b/docs/superpowers/specs/2026-07-17-at-session-reference-design.md @@ -0,0 +1,193 @@ +# `@` Session Reference + Tabbed Completion UI — Design + +Date: 2026-07-17 +Branch: `lazzy/at-session-ref` +Status: Implemented + +## 1. Goal + +Two related enhancements to the interactive `@` mention feature: + +1. **Reference prior sessions via `@`.** Let a user pull a _condensed_ copy of an + earlier chat session's history into the current context as reference material — + without having to `fork` the session. The reference is injected as read-only + context, not as a resumed/forked timeline. +2. **Tabbed `@` completion UI.** Because `@` now surfaces more categories + (files, directories, sessions, MCP servers/resources, extensions), redesign + the suggestion dropdown into a **tab-switched** layout so it stays usable. + +## 2. Decisions (locked) + +| Dimension | Decision | +| ---------------------------- | --------------------------------------------------------------------------------------------------------------------------- | +| How the session is condensed | **Mechanical slimming, NO LLM call** — deterministic | +| What slimming keeps | **user + assistant text + a one-line summary per tool call** (name + status; no bulky tool results) | +| Referenceable scope | **Current project only** (project-hash scoped, same as `resume`) | +| How `@` surfaces sessions | **Bare `@` shows a Sessions group** (no prefix required to discover); a `@session:` prefix also works for direct addressing | +| Completion UI shape | **Tab switch** — top tab bar (All / Files / Sessions / MCP / Extensions), single list below shows the active tab | +| Injected size cap | **Fixed token budget with tail-retention** — drop oldest turns first, mark as truncated | + +Explicitly rejected: LLM summarization (conflicts with the "inject slimmed +original, no model call" decision), reusing `ChatCompressionService` (it calls +the model and is coupled to a live `GeminiChat`), cross-project referencing. + +## 3. Architecture (Approach 1 — core service + localized UI changes) + +Split into two independent halves: **backend** (parse + load + slim + inject) +lives in core and is a pure, unit-testable function; **frontend** (tabbed +dropdown) is a localized render-layer + keybinding change. + +``` +packages/core/src/services/ + sessionReferenceService.ts [NEW] load → slim → budget-trim → injectable text + +packages/cli/src/ui/hooks/ + sessionMentionRef.ts [NEW] parse/build/validate @session: + atCommandProcessor.ts [EDIT] new @session: routing branch → service → Part + useAtCompletion.ts [EDIT] new session-suggestion producer (category tag) + useCompletion.ts [EDIT] track activeCategory; reset index on tab switch + +packages/cli/src/ui/components/ + SuggestionsDisplay.tsx [EDIT] tab bar + filter rows by active category + InputPrompt.tsx [EDIT] new keybinding to switch tab (←/→) +``` + +### Module responsibilities + +| Unit | Responsibility | In → Out | Reuses | +| --------------------------- | ---------------------------------------------- | ----------------------------------------------- | ----------------------------------------------------------------------- | +| `SessionReferenceService` | Turn a session id into injectable slimmed text | `sessionId` → `{ text, meta, truncated }` | `SessionService.loadSession`, `filterToDialog`, `estimateContentTokens` | +| `sessionMentionRef.ts` | Parse/build/validate `@session:` refs | string ↔ `{ id?, title? }` | — | +| `atCommandProcessor` (edit) | Route `@session:` → service → injected part | ref token → `Part` | `SessionReferenceService` | +| session producer (edit) | List referenceable sessions as suggestions | pattern → `Suggestion[]` (`category:'session'`) | `SessionService.listSessions` | +| `SuggestionsDisplay` (edit) | Render category tabs; show active tab's rows | `Suggestion[]` + `activeCategory` → TUI | — | + +## 4. Backend: session → injectable text + +1. **Resolve ref.** `@session:` where `` is a session UUID or a + (custom) title. UUID → direct; title → `SessionService.findSessionsByTitle` + (active, current-project only). Ambiguous title (>1 match) → the completion + UI already disambiguates; at submit time a still-ambiguous title is reported + as an unresolved mention (left as literal text, with a note), not guessed. + Note: title matching compares against the session's explicit `customTitle` + (set by auto-title or `/rename`), not the first-prompt label shown in the + completion dropdown. The picker always inserts `@session:`, so the + mainline path is unaffected; a hand-typed `@session:` resolves + only when it case-insensitively equals an existing session title. +2. **Load.** `SessionService.loadSession(id)` → `ConversationRecord.messages` + (`ChatRecord[]`). Guard: `sessionBelongsToCurrentProject` (already enforced + inside `loadSession`) — cross-project ids resolve to "not found". +3. **Slim (deterministic, no model call).** + - Reuse `filterToDialog` to keep user + assistant visible text, dropping + thoughts. + - For records that are tool calls (`message.parts` functionCall / + `toolCallResult`), emit a single line: `[tool: ]`. + Do **not** include tool result bodies. + - Preserve chronological order; render as a labeled block: + `--- Referenced session "" (slimmed, read-only) ---\n<body>`. +4. **Budget-trim.** Estimate with `estimateContentTokens`. Cap at a fixed budget + (default `SESSION_REF_TOKEN_BUDGET`, ~8k, configurable). If over budget, + **drop oldest turns first** (tail-retention) and prepend a + `[earlier turns omitted]` marker so the model knows it is truncated. + Return `truncated: true` in meta. +5. **Fast path.** If the loaded records contain a `chat_compression` record with + `systemPayload.compressedHistory`, that snapshot MAY be used as the slimmed + body directly (already condensed) — optional optimization, not required for v1. + +Output shape: + +```ts +interface SlimmedSessionReference { + text: string; // labeled, budget-trimmed block + meta: { + sessionId: string; + title: string; + messageCount: number; + approxTokens: number; + }; + truncated: boolean; +} +``` + +### Injection (atCommandProcessor) + +New routing branch, ordered **after** extension/MCP refs and **before** the +filesystem path fall-through (so `session:` with its `:` is never mistaken for a +path). Resolved session text is added to `scopedMentionParts` (same bucket as +MCP-server context), so final assembly stays grouped-by-type. The `@session:…` +token is left verbatim in the prompt text; the model correlates it with the +`--- Referenced session … ---` block. A tool-call display card +("Referenced session") is emitted, mirroring the existing Read File / Activate +Extension cards. Unresolved / not-found / cross-project refs fall back to literal +text with a surfaced note (never silently dropped). + +## 5. Frontend: tabbed completion UI + +- **Suggestion type.** Add `category?: SuggestionCategory` to `Suggestion` + (`'file' | 'session' | 'mcp' | 'extension'`). Existing `sourceBadge` stays for + inline labels; `category` drives tab grouping. Files with no tag default to + `'file'`. +- **Producer.** In `useAtCompletion.ts` add a session producer that calls + `SessionService.listSessions` (current project), maps each to a `Suggestion` + with `category:'session'`, `label` = title (fallback: first user prompt, + truncated), `value` = `@session:<id>`, `description` = first user prompt + (when session has a custom title; otherwise omitted). Shown on **bare `@`** (like extensions) and filtered by pattern. +- **Rendering.** `SuggestionsDisplay.tsx` gains an optional top tab bar modeled on + `StatsDialog`'s `StatsTabs` / `handleTabChange` / `useKeypress` trio: tabs are + `All` + each non-empty category. `All` shows every suggestion (current + behavior); a specific tab filters to that category. + When only one category is present, the tab bar is hidden (no regression for + plain file completion). +- **Keyboard.** `↑/↓` selects within the active tab (unchanged). Tab **switching** + uses `←/→` (and/or `Shift+Tab`) via a **new keybinding Command**, because + `Command.ACCEPT_SUGGESTION` already binds BOTH `Tab` and `Enter` in + `InputPrompt.tsx` — reusing `Tab` would collide. `useCompletion` tracks + `activeCategory` and resets `activeSuggestionIndex` / scroll on switch. +- **State ownership.** `activeCategory` lives in `useCompletion` (alongside + `activeSuggestionIndex`), so accept/scroll logic stays in one place. + +## 6. Error handling + +| Case | Behavior | +| -------------------------------------- | ----------------------------------------------------------------- | +| Session id not found / cross-project | Ref left as literal text + surfaced note; no throw | +| Title matches >1 session at submit | Reported as ambiguous unresolved mention (literal text + note) | +| Loaded history empty after slimming | Inject a short `(no textual content)` note instead of empty block | +| Over token budget | Tail-retain, prepend `[earlier turns omitted]`, `truncated:true` | +| `listSessions` fails (I/O) in producer | Session tab shows empty/"unavailable"; other tabs unaffected | +| Only one category available | Tab bar hidden; behaves exactly as today | + +## 7. Testing + +**Core (unit, no model):** + +- `sessionReferenceService`: slimming keeps user+assistant text; tool records + collapse to one-line summaries; tool result bodies excluded; chronological + order; budget trim drops oldest first + sets `truncated`; empty-history note; + cross-project id → not found. +- `sessionMentionRef`: parse/build/validate round-trip; UUID vs title; malformed. + +**CLI (component / hook):** + +- `atCommandProcessor`: `@session:<id>` routes to service and injects into + `scopedMentionParts`; ordering vs MCP/ext/file; unresolved → literal fallback; + display card emitted. +- `useAtCompletion`: session producer appears on bare `@`; filters by pattern; + current-project scoping. +- `SuggestionsDisplay`: tab bar renders per non-empty category; `All` shows all; + filtering by active category; single-category hides tab bar. +- `InputPrompt` / keybinding: `←/→` switches tab without triggering accept; + `Tab`/`Enter` still accept; index resets on switch. + +**Manual / e2e:** type `@`, switch to Sessions tab, pick a prior session, submit, +confirm the slimmed block reaches the model as a labeled context part and a +"Referenced session" card is shown. + +## 8. Out of scope (v1) + +- LLM-generated summaries of referenced sessions. +- Cross-project / cross-workspace session referencing. +- Referencing archived sessions by `@` (title search is active-only, matching + existing `findSessionsByTitle`). +- Left/right split-column dropdown layout (chose tab switch instead). +- Referencing a _range_ / specific messages within a session (whole-session only). diff --git a/packages/cli/src/config/keyBindings.ts b/packages/cli/src/config/keyBindings.ts index d9dcdf6df37..833fa02e740 100644 --- a/packages/cli/src/config/keyBindings.ts +++ b/packages/cli/src/config/keyBindings.ts @@ -39,6 +39,8 @@ export enum Command { ACCEPT_SUGGESTION = 'acceptSuggestion', COMPLETION_UP = 'completionUp', COMPLETION_DOWN = 'completionDown', + COMPLETION_TAB_LEFT = 'completionTabLeft', + COMPLETION_TAB_RIGHT = 'completionTabRight', // Text input SUBMIT = 'submit', @@ -185,6 +187,17 @@ export const defaultKeyBindings: KeyBindingConfig = { { key: 'down', shift: false }, { key: 'n', ctrl: true }, ], + // Completion category tab switching (for the tabbed @ completion UI). + // Bound to Ctrl+arrows rather than plain arrows so the bare arrow keys keep + // moving the caret in the editable input buffer (plain arrows only switch + // tabs in modal dialogs, which have no text buffer). Alt/Option+arrows still + // perform word movement. + [Command.COMPLETION_TAB_LEFT]: [ + { key: 'left', shift: false, ctrl: true, command: false }, + ], + [Command.COMPLETION_TAB_RIGHT]: [ + { key: 'right', shift: false, ctrl: true, command: false }, + ], // Text input // Must also exclude shift to allow shift+enter for newline diff --git a/packages/cli/src/ui/components/InputPrompt.test.tsx b/packages/cli/src/ui/components/InputPrompt.test.tsx index f0232864c80..809953844d5 100644 --- a/packages/cli/src/ui/components/InputPrompt.test.tsx +++ b/packages/cli/src/ui/components/InputPrompt.test.tsx @@ -317,6 +317,9 @@ describe('InputPrompt', () => { setActiveSuggestionIndex: vi.fn(), setShowSuggestions: vi.fn(), handleAutocomplete: vi.fn(), + activeCategory: 'all' as const, + availableCategories: ['all'] as Array<'all'>, + switchCategory: vi.fn(), }; mockedUseCommandCompletion.mockReturnValue(mockCommandCompletion); @@ -2549,6 +2552,126 @@ describe('InputPrompt', () => { unmount(); }); + it('should NOT switch category on left/right when availableCategories <= 2', async () => { + const switchCategory = vi.fn(); + mockedUseCommandCompletion.mockReturnValue({ + ...mockCommandCompletion, + completionMode: CompletionMode.AT, + showSuggestions: true, + suggestions: [ + { label: 'file.ts', value: 'file.ts' }, + { label: 'other.ts', value: 'other.ts' }, + ], + activeSuggestionIndex: 0, + isPerfectMatch: false, + availableCategories: ['all'], + switchCategory, + }); + props.buffer.setText('@file'); + + const { stdin, unmount } = renderWithProviders(<InputPrompt {...props} />); + await wait(); + + stdin.write('\x1b[C'); // right arrow + await wait(); + stdin.write('\x1b[D'); // left arrow + await wait(); + + expect(switchCategory).not.toHaveBeenCalled(); + unmount(); + }); + + it('should NOT switch category on Ctrl+left/right when availableCategories is exactly 2', async () => { + const switchCategory = vi.fn(); + mockedUseCommandCompletion.mockReturnValue({ + ...mockCommandCompletion, + completionMode: CompletionMode.AT, + showSuggestions: true, + suggestions: [{ label: 'file.ts', value: 'file.ts', category: 'file' }], + activeSuggestionIndex: 0, + isPerfectMatch: false, + availableCategories: ['all', 'file'], + switchCategory, + }); + props.buffer.setText('@file'); + + const { stdin, unmount } = renderWithProviders(<InputPrompt {...props} />); + await wait(); + + stdin.write('\x1b[1;5C'); // Ctrl+right arrow + await wait(); + stdin.write('\x1b[1;5D'); // Ctrl+left arrow + await wait(); + + // With only 2 entries (all + one real category) the tab bar is hidden, + // so Ctrl+arrows must not trigger category switching. + expect(switchCategory).not.toHaveBeenCalled(); + unmount(); + }); + + it('should switch category on Ctrl+left/right when availableCategories > 2', async () => { + const switchCategory = vi.fn(); + mockedUseCommandCompletion.mockReturnValue({ + ...mockCommandCompletion, + completionMode: CompletionMode.AT, + showSuggestions: true, + suggestions: [ + { label: 'file.ts', value: 'file.ts', category: 'file' }, + { label: 'sess', value: 'sess', category: 'session' }, + ], + activeSuggestionIndex: 0, + isPerfectMatch: false, + availableCategories: ['all', 'file', 'session'], + switchCategory, + }); + props.buffer.setText('@'); + + const { stdin, unmount } = renderWithProviders(<InputPrompt {...props} />); + await wait(); + + stdin.write('\x1b[1;5C'); // Ctrl+right arrow + await wait(); + + expect(switchCategory).toHaveBeenCalledWith(1); + + stdin.write('\x1b[1;5D'); // Ctrl+left arrow + await wait(); + + expect(switchCategory).toHaveBeenCalledWith(-1); + unmount(); + }); + + it('should NOT switch category on plain left/right when availableCategories > 2 (caret stays free)', async () => { + const switchCategory = vi.fn(); + mockedUseCommandCompletion.mockReturnValue({ + ...mockCommandCompletion, + completionMode: CompletionMode.AT, + showSuggestions: true, + suggestions: [ + { label: 'file.ts', value: 'file.ts', category: 'file' }, + { label: 'sess', value: 'sess', category: 'session' }, + ], + activeSuggestionIndex: 0, + isPerfectMatch: false, + availableCategories: ['all', 'file', 'session'], + switchCategory, + }); + props.buffer.setText('@'); + + const { stdin, unmount } = renderWithProviders(<InputPrompt {...props} />); + await wait(); + + stdin.write('\x1b[C'); // plain right arrow + await wait(); + stdin.write('\x1b[D'); // plain left arrow + await wait(); + + // Plain arrows must not be hijacked for tab switching, so they remain + // available to move the caret in the editable buffer. + expect(switchCategory).not.toHaveBeenCalled(); + unmount(); + }); + it('should reset history navigation after submitting on Enter', async () => { mockedUseCommandCompletion.mockReturnValue({ ...mockCommandCompletion, diff --git a/packages/cli/src/ui/components/InputPrompt.tsx b/packages/cli/src/ui/components/InputPrompt.tsx index 22d4ae36cc7..d94c965e457 100644 --- a/packages/cli/src/ui/components/InputPrompt.tsx +++ b/packages/cli/src/ui/components/InputPrompt.tsx @@ -1397,6 +1397,23 @@ export const InputPrompt: React.FC<InputPromptProps> = ({ } if (showCompletionSuggestions) { + // Category tab switching for the tabbed `@` completion UI. Only consume + // Ctrl+←/→ (per the COMPLETION_TAB_* bindings) and only when there are + // more than two tabs (at least 3 entries including 'all'). Plain ←/→ are + // never consumed here, so they always move the caret in the editable buffer. + if ((completion.availableCategories?.length ?? 0) > 2) { + if (keyMatchers[Command.COMPLETION_TAB_RIGHT](key)) { + completion.switchCategory(1); + setExpandedSuggestionIndex(-1); + return true; + } + if (keyMatchers[Command.COMPLETION_TAB_LEFT](key)) { + completion.switchCategory(-1); + setExpandedSuggestionIndex(-1); + return true; + } + } + if (completion.suggestions.length > 1) { const isCompletionUpKey = keyMatchers[Command.COMPLETION_UP](key); const isCompletionDownKey = keyMatchers[Command.COMPLETION_DOWN](key); @@ -2230,6 +2247,20 @@ export const InputPrompt: React.FC<InputPromptProps> = ({ } expandedIndex={expandedSuggestionIndex} mouseEnabled={mouseInteractionsEnabled} + activeCategory={ + suggestionsFromExport || + commandSearchActive || + reverseSearchActive + ? undefined + : completion.activeCategory + } + availableCategories={ + suggestionsFromExport || + commandSearchActive || + reverseSearchActive + ? undefined + : completion.availableCategories + } onHoverIndex={ suggestionsFromExport ? undefined : handleSuggestionHover } diff --git a/packages/cli/src/ui/components/SuggestionsDisplay.test.tsx b/packages/cli/src/ui/components/SuggestionsDisplay.test.tsx index 87cd2c8c9aa..c6959963568 100644 --- a/packages/cli/src/ui/components/SuggestionsDisplay.test.tsx +++ b/packages/cli/src/ui/components/SuggestionsDisplay.test.tsx @@ -171,6 +171,66 @@ describe('SuggestionsDisplay', () => { }); }); +describe('SuggestionsDisplay tabs', () => { + const mixed = [ + { label: 'a.ts', value: 'a.ts', category: 'file' as const }, + { label: 'Fix bug', value: 'session:id-1', category: 'session' as const }, + ]; + + it('shows a tab bar when multiple categories are present', () => { + const { lastFrame } = render( + <SuggestionsDisplay + suggestions={mixed} + activeIndex={0} + isLoading={false} + width={80} + scrollOffset={0} + userInput="" + mode="reverse" + activeCategory="all" + availableCategories={['all', 'file', 'session']} + />, + ); + expect(lastFrame()).toContain('Files'); + expect(lastFrame()).toContain('Sessions'); + }); + + it('filters rows to the active category', () => { + const { lastFrame } = render( + <SuggestionsDisplay + suggestions={mixed} + activeIndex={0} + isLoading={false} + width={80} + scrollOffset={0} + userInput="" + mode="reverse" + activeCategory="session" + availableCategories={['all', 'file', 'session']} + />, + ); + expect(lastFrame()).toContain('Fix bug'); + expect(lastFrame()).not.toContain('a.ts'); + }); + + it('hides the tab bar for single-category (file-only) completion', () => { + const { lastFrame } = render( + <SuggestionsDisplay + suggestions={[mixed[0]]} + activeIndex={0} + isLoading={false} + width={80} + scrollOffset={0} + userInput="" + mode="reverse" + activeCategory="all" + availableCategories={['all', 'file']} + />, + ); + expect(lastFrame()).not.toContain('Files'); + }); +}); + describe('normalizeDescription', () => { it('collapses all whitespace runs into single spaces and trims', () => { expect(normalizeDescription(' a\n\nb\t c ')).toBe('a b c'); diff --git a/packages/cli/src/ui/components/SuggestionsDisplay.tsx b/packages/cli/src/ui/components/SuggestionsDisplay.tsx index 2cf9acac56b..f69ff5fb392 100644 --- a/packages/cli/src/ui/components/SuggestionsDisplay.tsx +++ b/packages/cli/src/ui/components/SuggestionsDisplay.tsx @@ -14,10 +14,11 @@ import { t } from '../../i18n/index.js'; import { MAX_SUGGESTIONS_TO_SHOW, type Suggestion, + type SuggestionCategory, } from '../utils/suggestions.js'; export { MAX_SUGGESTIONS_TO_SHOW } from '../utils/suggestions.js'; -export type { Suggestion } from '../utils/suggestions.js'; +export type { Suggestion, SuggestionCategory } from '../utils/suggestions.js'; interface SuggestionsDisplayProps { suggestions: Suggestion[]; @@ -34,6 +35,33 @@ interface SuggestionsDisplayProps { onSelectIndex?: (index: number) => void; /** Whether mouse interactions are enabled (alternate-screen mode + setting). */ mouseEnabled?: boolean; + /** + * Active category tab for the `@` completion UI. When set and not 'all', + * only suggestions of this category are rendered. Defaults to 'all'. + * The parent (useCompletion) filters the array it manages scroll/active + * state against; this prop drives the tab bar rendering + a defensive + * in-component filter. + */ + activeCategory?: SuggestionCategory | 'all'; + /** Ordered list of tabs to show. The tab bar renders only when >2 entries. */ + availableCategories?: Array<SuggestionCategory | 'all'>; +} + +function categoryLabel(cat: SuggestionCategory | 'all'): string { + switch (cat) { + case 'all': + return t('All'); + case 'file': + return t('Files'); + case 'session': + return t('Sessions'); + case 'mcp': + return t('MCP'); + case 'extension': + return t('Extensions'); + default: + return cat; + } } export { MAX_WIDTH }; @@ -68,6 +96,8 @@ export function SuggestionsDisplay({ onHoverIndex, onSelectIndex, mouseEnabled, + activeCategory = 'all', + availableCategories, }: SuggestionsDisplayProps) { const containerRef = useRef<DOMElement | null>(null); const itemRefs = useRef<Array<DOMElement | null>>([]); @@ -80,7 +110,17 @@ export function SuggestionsDisplay({ ); } - if (suggestions.length === 0) { + // Defensive filter: the parent normally hands us the already-filtered list + // for the active tab (so scroll/active-index line up), but filtering here too + // keeps rendering correct if a caller passes the full list. + const filteredSuggestions = + activeCategory === 'all' + ? suggestions + : suggestions.filter((s) => (s.category ?? 'file') === activeCategory); + + const showTabBar = (availableCategories?.length ?? 0) > 2; + + if (filteredSuggestions.length === 0) { return null; // Don't render anything if there are no suggestions } @@ -88,15 +128,15 @@ export function SuggestionsDisplay({ const startIndex = scrollOffset; const endIndex = Math.min( scrollOffset + MAX_SUGGESTIONS_TO_SHOW, - suggestions.length, + filteredSuggestions.length, ); - const visibleSuggestions = suggestions.slice(startIndex, endIndex); + const visibleSuggestions = filteredSuggestions.slice(startIndex, endIndex); const getFullLabel = (s: Suggestion) => [s.label, s.argumentHint, s.sourceBadge].filter(Boolean).join(' '); const maxLabelLength = Math.max( - ...suggestions.map((s) => getFullLabel(s).length), + ...filteredSuggestions.map((s) => getFullLabel(s).length), ); // Width of the left label column. In slash mode every row shares one // half-width command column. In @-mention (reverse) mode only rows WITH a @@ -105,7 +145,7 @@ export function SuggestionsDisplay({ // up, capped so the description keeps a minimum readable width — while plain // file rows (no description) keep the full row width. The reference takes // priority over its description, which truncates. - const describedLabelLengths = suggestions + const describedLabelLengths = filteredSuggestions .filter((s) => s.description) .map((s) => getFullLabel(s).length); const contentWidth = Math.max(width - ACTIVE_MARKER_WIDTH, 1); @@ -130,6 +170,30 @@ export function SuggestionsDisplay({ onSelectIndex={onSelectIndex} /> )} + {showTabBar && availableCategories && ( + <Box flexDirection="row" marginBottom={1}> + {availableCategories.map((cat, i) => { + const active = cat === activeCategory; + return ( + <Box key={cat} marginLeft={i === 0 ? 0 : 1}> + <Text + color={ + active ? theme.background.primary : theme.text.secondary + } + backgroundColor={active ? theme.text.accent : undefined} + > + {` ${categoryLabel(cat)} `} + </Text> + </Box> + ); + })} + <Box marginLeft={2}> + <Text color={theme.text.secondary}> + {t('(Ctrl+←/→ to switch)')} + </Text> + </Box> + </Box> + )} {scrollOffset > 0 && <Text color={theme.text.primary}>▲</Text>} {visibleSuggestions.map((suggestion, index) => { @@ -204,10 +268,10 @@ export function SuggestionsDisplay({ </Box> ); })} - {endIndex < suggestions.length && <Text color="gray">▼</Text>} - {suggestions.length > MAX_SUGGESTIONS_TO_SHOW && ( + {endIndex < filteredSuggestions.length && <Text color="gray">▼</Text>} + {filteredSuggestions.length > MAX_SUGGESTIONS_TO_SHOW && ( <Text color="gray"> - ({activeIndex + 1}/{suggestions.length}) + ({activeIndex + 1}/{filteredSuggestions.length}) </Text> )} </Box> diff --git a/packages/cli/src/ui/hooks/atCommandProcessor.session.test.ts b/packages/cli/src/ui/hooks/atCommandProcessor.session.test.ts new file mode 100644 index 00000000000..c3417c9c565 --- /dev/null +++ b/packages/cli/src/ui/hooks/atCommandProcessor.session.test.ts @@ -0,0 +1,277 @@ +/** + * @license + * Copyright 2025 Qwen + * SPDX-License-Identifier: Apache-2.0 + */ + +import type { Mock } from 'vitest'; +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; + +const mockResolve = vi.fn(); +const mockFindSessionsByTitle = vi.fn(); + +vi.mock('@qwen-code/qwen-code-core', async (orig) => { + const actual = (await orig()) as Record<string, unknown>; + return { + ...actual, + SessionReferenceService: class { + resolve = mockResolve; + }, + SessionService: class { + findSessionsByTitle = mockFindSessionsByTitle; + }, + }; +}); + +import { handleAtCommand } from './atCommandProcessor.js'; +import { ToolCallStatus } from '../types.js'; +import type { Config } from '@qwen-code/qwen-code-core'; +import { + FileDiscoveryService, + StandardFileSystemService, + COMMON_IGNORE_PATTERNS, +} from '@qwen-code/qwen-code-core'; +import * as os from 'node:os'; +import type { UseHistoryManagerReturn } from './useHistoryManager.js'; +import * as fsPromises from 'node:fs/promises'; +import * as path from 'node:path'; + +const UUID = '3f2504e0-4f89-41d3-9a0c-0305e82c3301'; + +describe('handleAtCommand @session:', () => { + let testRootDir: string; + let mockConfig: Config; + const mockAddItem: Mock<UseHistoryManagerReturn['addItem']> = vi.fn(); + const mockOnDebugMessage: Mock<(message: string) => void> = vi.fn(); + let abortController: AbortController; + + beforeEach(async () => { + vi.clearAllMocks(); + testRootDir = await fsPromises.mkdtemp( + path.join(os.tmpdir(), 'at-session-test-'), + ); + abortController = new AbortController(); + mockConfig = { + getTargetDir: () => testRootDir, + getProjectRoot: () => testRootDir, + isSandboxed: () => false, + getFileService: () => new FileDiscoveryService(testRootDir), + getFileFilteringOptions: () => ({ + respectGitIgnore: true, + respectQwenIgnore: true, + }), + getFileSystemService: () => new StandardFileSystemService(), + getEnableRecursiveFileSearch: vi.fn(() => true), + getWorkspaceContext: () => ({ + isPathWithinWorkspace: () => true, + getDirectories: () => [testRootDir], + }), + getMcpServers: () => ({}), + getActiveExtensions: () => [], + getToolRegistry: () => ({}), + getDebugMode: () => false, + getFileExclusions: () => ({ + getCoreIgnorePatterns: () => COMMON_IGNORE_PATTERNS, + getReadManyFilesExcludes: () => [], + }), + } as unknown as Config; + }); + + afterEach(async () => { + abortController.abort(); + await fsPromises.rm(testRootDir, { recursive: true, force: true }); + }); + + it('injects slimmed session text for a valid @session:<uuid>', async () => { + mockResolve.mockResolvedValue({ + text: '--- Referenced session "s1" (slimmed, read-only) ---\nUser: hi', + meta: { sessionId: UUID, title: 's1', messageCount: 1, approxTokens: 5 }, + truncated: false, + }); + const result = await handleAtCommand({ + query: `see @session:${UUID} please`, + config: mockConfig, + addItem: mockAddItem, + onDebugMessage: mockOnDebugMessage, + messageId: 1, + signal: abortController.signal, + }); + expect(result.shouldProceed).toBe(true); + const joined = JSON.stringify(result.processedQuery); + expect(joined).toContain('User: hi'); + // @session: token kept verbatim in the prompt text + expect(joined).toContain(`@session:${UUID}`); + // a display card is emitted + expect( + result.toolDisplays?.some((d) => d.name === 'Referenced Session'), + ).toBe(true); + }); + + it('resolves a title to a single session', async () => { + mockFindSessionsByTitle.mockResolvedValue([{ sessionId: UUID }]); + mockResolve.mockResolvedValue({ + text: '--- Referenced session "My Chat" (slimmed, read-only) ---\nUser: hi', + meta: { + sessionId: UUID, + title: 'My Chat', + messageCount: 1, + approxTokens: 5, + }, + truncated: false, + }); + const result = await handleAtCommand({ + // spaces in a title must be escaped, exactly like file paths + query: '@session:My\\ Chat', + config: mockConfig, + addItem: mockAddItem, + onDebugMessage: mockOnDebugMessage, + messageId: 2, + signal: abortController.signal, + }); + expect(mockFindSessionsByTitle).toHaveBeenCalledWith('My Chat'); + expect(mockResolve).toHaveBeenCalledWith(UUID, { title: 'My Chat' }); + expect(JSON.stringify(result.processedQuery)).toContain('User: hi'); + }); + + it('falls back to literal text with an error card when not found', async () => { + mockResolve.mockResolvedValue({ notFound: true }); + const result = await handleAtCommand({ + query: `look @session:${UUID}`, + config: mockConfig, + addItem: mockAddItem, + onDebugMessage: mockOnDebugMessage, + messageId: 3, + signal: abortController.signal, + }); + expect(result.shouldProceed).toBe(true); + const joined = JSON.stringify(result.processedQuery); + // literal token retained + expect(joined).toContain(`@session:${UUID}`); + // an error card explains the miss + expect( + result.toolDisplays?.some( + (d) => + d.name === 'Referenced Session' && d.status === ToolCallStatus.Error, + ), + ).toBe(true); + }); + + it('reports an ambiguous title without guessing', async () => { + mockFindSessionsByTitle.mockResolvedValue([ + { sessionId: UUID }, + { sessionId: 'other' }, + ]); + const result = await handleAtCommand({ + query: '@session:Ambiguous', + config: mockConfig, + addItem: mockAddItem, + onDebugMessage: mockOnDebugMessage, + messageId: 4, + signal: abortController.signal, + }); + expect(mockResolve).not.toHaveBeenCalled(); + expect(result.shouldProceed).toBe(true); + expect(JSON.stringify(result.processedQuery)).toContain( + '@session:Ambiguous', + ); + const card = result.toolDisplays?.find( + (d) => d.name === 'Referenced Session', + ); + expect(card).toBeDefined(); + expect(card!.resultDisplay).toContain('ambiguous'); + }); + + it('survives a filesystem error during title lookup', async () => { + mockFindSessionsByTitle.mockRejectedValue( + Object.assign(new Error('EACCES'), { code: 'EACCES' }), + ); + const result = await handleAtCommand({ + query: '@session:Some\\ Title', + config: mockConfig, + addItem: mockAddItem, + onDebugMessage: mockOnDebugMessage, + messageId: 5, + signal: abortController.signal, + }); + expect(result.shouldProceed).toBe(true); + const joined = JSON.stringify(result.processedQuery); + expect(joined).toContain('@session:Some Title'); + const card = result.toolDisplays?.find( + (d) => d.name === 'Referenced Session', + ); + expect(card).toBeDefined(); + expect(card!.resultDisplay).toContain('EACCES'); + expect(mockResolve).not.toHaveBeenCalled(); + }); + + it('survives a load error during session resolve', async () => { + mockResolve.mockRejectedValue( + Object.assign(new Error('corrupted JSONL'), { code: 'EIO' }), + ); + const result = await handleAtCommand({ + query: `see @session:${UUID} please`, + config: mockConfig, + addItem: mockAddItem, + onDebugMessage: mockOnDebugMessage, + messageId: 6, + signal: abortController.signal, + }); + expect(result.shouldProceed).toBe(true); + const joined = JSON.stringify(result.processedQuery); + expect(joined).toContain(`@session:${UUID}`); + const card = result.toolDisplays?.find( + (d) => d.name === 'Referenced Session', + ); + expect(card).toBeDefined(); + expect(card!.resultDisplay).toContain('corrupted JSONL'); + }); + + it('deduplicates cross-form refs (UUID + title) resolving to the same session', async () => { + mockFindSessionsByTitle.mockResolvedValue([{ sessionId: UUID }]); + mockResolve.mockResolvedValue({ + text: '--- Referenced session "s1" (slimmed, read-only) ---\nUser: hi', + meta: { sessionId: UUID, title: 's1', messageCount: 1, approxTokens: 5 }, + truncated: false, + }); + const result = await handleAtCommand({ + query: `compare @session:${UUID} with @session:My\\ Chat`, + config: mockConfig, + addItem: mockAddItem, + onDebugMessage: mockOnDebugMessage, + messageId: 8, + signal: abortController.signal, + }); + expect(result.shouldProceed).toBe(true); + // Both refs resolve to the same session id — resolve called only once + expect(mockResolve).toHaveBeenCalledTimes(1); + const cards = result.toolDisplays?.filter( + (d) => d.name === 'Referenced Session', + ); + expect(cards).toHaveLength(1); + expect(mockOnDebugMessage).toHaveBeenCalledWith( + expect.stringContaining('already referenced'), + ); + }); + + it('deduplicates identical session mentions', async () => { + mockResolve.mockResolvedValue({ + text: '--- Referenced session "s1" (slimmed, read-only) ---\nUser: hi', + meta: { sessionId: UUID, title: 's1', messageCount: 1, approxTokens: 5 }, + truncated: false, + }); + const result = await handleAtCommand({ + query: `compare @session:${UUID} with @session:${UUID}`, + config: mockConfig, + addItem: mockAddItem, + onDebugMessage: mockOnDebugMessage, + messageId: 7, + signal: abortController.signal, + }); + expect(result.shouldProceed).toBe(true); + expect(mockResolve).toHaveBeenCalledTimes(1); + const cards = result.toolDisplays?.filter( + (d) => d.name === 'Referenced Session', + ); + expect(cards).toHaveLength(1); + }); +}); diff --git a/packages/cli/src/ui/hooks/atCommandProcessor.ts b/packages/cli/src/ui/hooks/atCommandProcessor.ts index 3659f326585..9dad69f6ef4 100644 --- a/packages/cli/src/ui/hooks/atCommandProcessor.ts +++ b/packages/cli/src/ui/hooks/atCommandProcessor.ts @@ -19,6 +19,8 @@ import { emptyMcpResourceText, formatMcpResourceContents, summarizeMcpResource, + SessionService, + SessionReferenceService, } from '@qwen-code/qwen-code-core'; import type { HistoryItemToolGroup, @@ -32,6 +34,7 @@ import { matchExtensionByRef, buildExtensionRef, } from './extension-mention-ref.js'; +import { parseSessionRef, buildSessionRef } from './session-mention-ref.js'; import { buildExtensionMentionContext, EXTENSION_CONTEXT_BUDGET, @@ -240,6 +243,14 @@ export async function resolveAtCommandQuery({ extension: Extension; }> = []; + // Session references (`@session:<id|title>`) collected during the loop and + // resolved after it. Each resolves to a slimmed, read-only block of a prior + // session's history injected as reference context (never a fork/resume). + const sessionMentions: Array<{ + originalAtPath: string; + ref: { id?: string; title?: string }; + }> = []; + for (const atPathPart of atPathCommandParts) { const originalAtPath = atPathPart.content; // e.g., "@file.txt" or "@" @@ -274,6 +285,25 @@ export async function resolveAtCommandQuery({ continue; } + // Session reference (`@session:<id|title>`): detected BEFORE MCP and + // filesystem resolution so the ':' in the token isn't mistaken for a path + // or intercepted by an MCP server literally named "session". Resolution + // (load + slim) happens after the loop; here we only collect and keep the + // token verbatim in the prompt text. + const sessionRef = parseSessionRef(pathName); + if (sessionRef) { + if ( + !sessionMentions.some( + (m) => + (m.ref.id ?? m.ref.title) === (sessionRef.id ?? sessionRef.title), + ) + ) { + sessionMentions.push({ originalAtPath, ref: sessionRef }); + } + atPathToResolvedSpecMap.set(originalAtPath, pathName); + continue; + } + // MCP resource reference (`@server:uri`): detected BEFORE filesystem // resolution so a resource URI containing ':' / '//' isn't mistaken for // a path. Only matches when `server` is a configured MCP server; all @@ -534,7 +564,8 @@ export async function resolveAtCommandQuery({ pathSpecsToRead.length === 0 && mcpResourceRefs.length === 0 && mcpServerMentions.length === 0 && - extensionMentions.length === 0 + extensionMentions.length === 0 && + sessionMentions.length === 0 ) { onDebugMessage('No valid file paths found in @ commands to read.'); if (initialQueryText === '@' && query.trim() === '@') { @@ -608,6 +639,154 @@ export async function resolveAtCommandQuery({ }); } + // Resolve session references: load + deterministically slim a prior session + // and inject it as a read-only reference block. A miss (not-found / ambiguous + // title) surfaces an error card and leaves the `@session:` token as literal + // text (already retained above), never aborting the turn. + const resolvedSessionIds = new Set<string>(); + for (let i = 0; i < sessionMentions.length; i++) { + const { originalAtPath, ref } = sessionMentions[i]; + const callId = `client-session-${userMessageTimestamp}-${i}`; + + let sessionId = ref.id; + if (!sessionId && ref.title) { + let matches: Array<{ sessionId: string }> = []; + try { + matches = await new SessionService( + config.getProjectRoot(), + ).findSessionsByTitle(ref.title); + } catch (error: unknown) { + const reason = `Could not look up sessions matching "@${originalAtPath.substring(1)}" (${getErrorMessage(error)}); try a session id instead.`; + onDebugMessage(reason); + scopedMentionEntries.push({ + originalAtPath, + part: { text: '' }, + label: buildSessionRef(ref.title ?? originalAtPath), + display: { + callId, + name: 'Referenced Session', + description: `Reference session "${ref.title ?? ''}"`, + status: ToolCallStatus.Error, + resultDisplay: reason, + confirmationDetails: undefined, + }, + }); + continue; + } + if (matches.length === 1) { + sessionId = matches[0].sessionId; + } else { + const reason = + matches.length === 0 + ? `No session matches "@${originalAtPath.substring(1)}".` + : `"@${originalAtPath.substring(1)}" is ambiguous (${matches.length} matches); use the picker or a session id.`; + onDebugMessage(reason); + scopedMentionEntries.push({ + originalAtPath, + part: { text: '' }, + label: buildSessionRef(ref.title), + display: { + callId, + name: 'Referenced Session', + description: `Reference session "${ref.title}"`, + status: ToolCallStatus.Error, + resultDisplay: reason, + confirmationDetails: undefined, + }, + }); + continue; + } + } + + if (!sessionId) { + const reason = `Session reference "@${originalAtPath.substring(1)}" could not be resolved.`; + onDebugMessage(reason); + scopedMentionEntries.push({ + originalAtPath, + part: { text: '' }, + label: buildSessionRef(ref.title ?? ref.id ?? originalAtPath), + display: { + callId, + name: 'Referenced Session', + description: `Reference session "${ref.title ?? ref.id ?? ''}"`, + status: ToolCallStatus.Error, + resultDisplay: reason, + confirmationDetails: undefined, + }, + }); + continue; + } + + // Cross-form dedup: a UUID ref and a title ref may resolve to the + // same session — skip if already injected. + if (resolvedSessionIds.has(sessionId)) { + onDebugMessage( + `Session reference "@${originalAtPath.substring(1)}" resolves to session ${sessionId}, which was already referenced; skipping duplicate.`, + ); + continue; + } + resolvedSessionIds.add(sessionId); + + let resolved; + try { + resolved = await new SessionReferenceService( + config.getProjectRoot(), + ).resolve(sessionId, ref.title ? { title: ref.title } : {}); + } catch (error: unknown) { + const reason = `Failed to load session "${sessionId}" (${getErrorMessage(error)}); the transcript may be corrupted or unreadable.`; + onDebugMessage(reason); + scopedMentionEntries.push({ + originalAtPath, + part: { text: '' }, + label: buildSessionRef(sessionId), + display: { + callId, + name: 'Referenced Session', + description: `Reference session ${sessionId}`, + status: ToolCallStatus.Error, + resultDisplay: reason, + confirmationDetails: undefined, + }, + }); + continue; + } + + if ('notFound' in resolved) { + const reason = `Session "${sessionId}" not found in this project.`; + onDebugMessage(reason); + scopedMentionEntries.push({ + originalAtPath, + part: { text: '' }, + label: buildSessionRef(sessionId), + display: { + callId, + name: 'Referenced Session', + description: `Reference session ${sessionId}`, + status: ToolCallStatus.Error, + resultDisplay: reason, + confirmationDetails: undefined, + }, + }); + continue; + } + + scopedMentionEntries.push({ + originalAtPath, + part: { text: resolved.text }, + label: buildSessionRef(sessionId), + display: { + callId, + name: 'Referenced Session', + description: `Referenced session "${resolved.meta.title}"${ + resolved.truncated ? ' (truncated)' : '' + }`, + status: ToolCallStatus.Success, + resultDisplay: undefined, + confirmationDetails: undefined, + }, + }); + } + const scopedMentionOrder = new Map( atPathCommandParts.map((part, index) => [part.content, index]), ); diff --git a/packages/cli/src/ui/hooks/extension-mention-ref.test.ts b/packages/cli/src/ui/hooks/extension-mention-ref.test.ts index 772ed0db5b2..86dfb897b76 100644 --- a/packages/cli/src/ui/hooks/extension-mention-ref.test.ts +++ b/packages/cli/src/ui/hooks/extension-mention-ref.test.ts @@ -187,6 +187,7 @@ describe('getExtensionSuggestions', () => { expect(suggestions[0]!.sourceBadge).toBe('Extension'); expect(suggestions[0]!.description).toBe('Test description'); expect(suggestions[0]!.isDirectory).toBe(false); + expect(suggestions[0]!.category).toBe('extension'); }); it('returns empty when folder is not trusted', () => { diff --git a/packages/cli/src/ui/hooks/extension-mention-ref.ts b/packages/cli/src/ui/hooks/extension-mention-ref.ts index 1e69fb3ff2c..f22a8187a24 100644 --- a/packages/cli/src/ui/hooks/extension-mention-ref.ts +++ b/packages/cli/src/ui/hooks/extension-mention-ref.ts @@ -65,5 +65,6 @@ export function getExtensionSuggestions( : undefined, sourceBadge: t('Extension'), isDirectory: false, + category: 'extension' as const, })); } diff --git a/packages/cli/src/ui/hooks/session-completion.test.ts b/packages/cli/src/ui/hooks/session-completion.test.ts new file mode 100644 index 00000000000..6f357f74a86 --- /dev/null +++ b/packages/cli/src/ui/hooks/session-completion.test.ts @@ -0,0 +1,179 @@ +/** + * @license + * Copyright 2025 Qwen + * SPDX-License-Identifier: Apache-2.0 + */ + +import { describe, it, expect, vi, beforeEach } from 'vitest'; + +const mockListSessions = vi.fn(); + +vi.mock('@qwen-code/qwen-code-core', async (orig) => { + const actual = (await orig()) as Record<string, unknown>; + return { + ...actual, + SessionService: class { + listSessions = mockListSessions; + }, + }; +}); + +import { + getSessionSuggestions, + __resetSessionSuggestionCacheForTest, +} from './session-completion.js'; + +beforeEach(() => { + mockListSessions.mockReset(); + __resetSessionSuggestionCacheForTest(); +}); + +describe('getSessionSuggestions', () => { + it('maps sessions to category:session suggestions with @session: values', async () => { + mockListSessions.mockResolvedValue({ + items: [ + { + sessionId: 'id-1', + customTitle: 'Fix auth bug', + prompt: 'fix auth', + mtime: 2, + }, + { + sessionId: 'id-2', + customTitle: undefined, + prompt: 'add tests', + mtime: 1, + }, + ], + hasMore: false, + }); + const out = await getSessionSuggestions('/proj', ''); + expect(out).toHaveLength(2); + expect(out[0]).toMatchObject({ + label: 'Fix auth bug', + value: 'session:id-1', + category: 'session', + }); + // falls back to first prompt when no custom title + expect(out[1].label).toBe('add tests'); + }); + + it('filters by pattern against title and prompt', async () => { + mockListSessions.mockResolvedValue({ + items: [ + { + sessionId: 'id-1', + customTitle: 'Fix auth bug', + prompt: 'fix auth', + mtime: 2, + }, + { + sessionId: 'id-2', + customTitle: undefined, + prompt: 'add tests', + mtime: 1, + }, + ], + hasMore: false, + }); + const out = await getSessionSuggestions('/proj', 'auth'); + expect(out).toHaveLength(1); + expect(out[0].value).toBe('session:id-1'); + }); + + it('strips the session: prefix before filtering', async () => { + mockListSessions.mockResolvedValue({ + items: [ + { + sessionId: 'id-1', + customTitle: 'Fix auth bug', + prompt: 'fix auth', + mtime: 2, + }, + { + sessionId: 'id-2', + customTitle: undefined, + prompt: 'add tests', + mtime: 1, + }, + ], + hasMore: false, + }); + const out = await getSessionSuggestions('/proj', 'session:auth'); + expect(out).toHaveLength(1); + expect(out[0].value).toBe('session:id-1'); + }); + + it('treats bare "session" (no colon) as an empty filter', async () => { + mockListSessions.mockResolvedValue({ + items: [ + { + sessionId: 'id-1', + customTitle: 'Fix auth bug', + prompt: 'fix auth', + mtime: 2, + }, + { + sessionId: 'id-2', + customTitle: undefined, + prompt: 'add tests', + mtime: 1, + }, + ], + hasMore: false, + }); + const out = await getSessionSuggestions('/proj', 'session'); + expect(out).toHaveLength(2); + }); + + it('returns [] when listSessions throws (I/O failure)', async () => { + mockListSessions.mockRejectedValue(new Error('disk gone')); + const out = await getSessionSuggestions('/proj', ''); + expect(out).toEqual([]); + }); + + it('caches the listing within the TTL (no re-list on pattern change)', async () => { + mockListSessions.mockResolvedValue({ + items: [ + { + sessionId: 'id-1', + customTitle: 'Fix auth bug', + prompt: 'fix auth', + mtime: 2, + }, + { + sessionId: 'id-2', + customTitle: undefined, + prompt: 'add tests', + mtime: 1, + }, + ], + hasMore: false, + }); + // Three keystrokes within the TTL window; filtering still applies each time. + const a = await getSessionSuggestions('/proj', '', 1000); + const b = await getSessionSuggestions('/proj', 'auth', 1500); + const c = await getSessionSuggestions('/proj', 'tests', 2000); + expect(a).toHaveLength(2); + expect(b).toHaveLength(1); + expect(b[0].value).toBe('session:id-1'); + expect(c).toHaveLength(1); + expect(c[0].value).toBe('session:id-2'); + // Listed from disk only ONCE despite three calls. + expect(mockListSessions).toHaveBeenCalledTimes(1); + }); + + it('re-lists after the TTL expires', async () => { + mockListSessions.mockResolvedValue({ items: [], hasMore: false }); + await getSessionSuggestions('/proj', '', 1000); + await getSessionSuggestions('/proj', '', 1000 + 10_000); // well past TTL + expect(mockListSessions).toHaveBeenCalledTimes(2); + }); + + it('caches per cwd (different project re-lists)', async () => { + mockListSessions.mockResolvedValue({ items: [], hasMore: false }); + await getSessionSuggestions('/proj-a', '', 1000); + await getSessionSuggestions('/proj-b', '', 1000); + expect(mockListSessions).toHaveBeenCalledTimes(2); + }); +}); diff --git a/packages/cli/src/ui/hooks/session-completion.ts b/packages/cli/src/ui/hooks/session-completion.ts new file mode 100644 index 00000000000..bcdd40d1c10 --- /dev/null +++ b/packages/cli/src/ui/hooks/session-completion.ts @@ -0,0 +1,112 @@ +/** + * @license + * Copyright 2025 Qwen + * SPDX-License-Identifier: Apache-2.0 + */ + +import { SessionService } from '@qwen-code/qwen-code-core'; +import type { SessionListItem } from '@qwen-code/qwen-code-core'; +import type { Suggestion } from '../components/SuggestionsDisplay.js'; +import { + buildSessionRef, + SESSION_MENTION_PREFIX, +} from './session-mention-ref.js'; +import { t } from '../../i18n/index.js'; + +const MAX_SESSION_SUGGESTIONS = 20; + +/** + * Short TTL for the per-cwd session listing cache. Listing walks the chats + * dir (synchronous readdir/stat plus a bounded tail read per file), so + * re-running it on every keystroke adds visible input latency. A few seconds + * is short enough that a freshly created/renamed session still appears + * promptly, while a burst of keystrokes reuses one listing. + */ +const SESSION_LIST_CACHE_TTL_MS = 3000; + +interface CacheEntry { + items: SessionListItem[]; + expiresAt: number; +} + +// Cache the UNFILTERED listing keyed by cwd; pattern filtering is cheap and +// always applied fresh below. +const listingCache = new Map<string, CacheEntry>(); + +/** Test-only: clear the module-level listing cache between cases. */ +export function __resetSessionSuggestionCacheForTest(): void { + listingCache.clear(); +} + +async function listSessionsCached( + cwd: string, + nowMs: number, +): Promise<SessionListItem[]> { + const cached = listingCache.get(cwd); + if (cached && cached.expiresAt > nowMs) { + return cached.items; + } + try { + const res = await new SessionService(cwd).listSessions({ + size: MAX_SESSION_SUGGESTIONS, + }); + listingCache.set(cwd, { + items: res.items, + expiresAt: nowMs + SESSION_LIST_CACHE_TTL_MS, + }); + return res.items; + } catch { + // Listing failure: cache nothing so the next keystroke retries, and yield + // an empty list so file/MCP/extension completion is never blocked. + return []; + } +} + +/** + * Lists prior sessions for the current project as `@` completion suggestions. + * Scope is enforced by SessionService (current project only). The disk listing + * is cached per cwd for a short TTL (see {@link SESSION_LIST_CACHE_TTL_MS}) so + * rapid keystrokes don't re-walk the chats directory; pattern filtering runs + * fresh on the cached items. A listing failure yields an empty list so the + * Sessions tab simply shows nothing rather than breaking file/MCP/extension + * completion. + * + * @param nowMs Injected clock for the cache TTL (defaults to Date.now()). + * Exposed for deterministic tests. + */ +export async function getSessionSuggestions( + cwd: string, + pattern: string, + nowMs: number = Date.now(), +): Promise<Suggestion[]> { + const items = await listSessionsCached(cwd, nowMs); + + // Strip the `session:` prefix when present. A bare `@session` (no colon) + // is treated as an empty filter so the user sees all sessions rather than + // filtering by the literal word "session". + const stripped = pattern.startsWith(SESSION_MENTION_PREFIX) + ? pattern.slice(SESSION_MENTION_PREFIX.length) + : pattern.toLowerCase() === 'session' + ? '' + : pattern; + const needle = stripped.trim().toLowerCase(); + return items + .map((s) => { + const label = s.customTitle?.trim() || s.prompt || s.sessionId; + const description = s.customTitle ? s.prompt : undefined; + return { + label, + value: buildSessionRef(s.sessionId), + description, + sourceBadge: t('Session'), + category: 'session' as const, + } satisfies Suggestion; + }) + .filter((sug) => + needle.length === 0 + ? true + : `${sug.label} ${sug.description ?? ''}` + .toLowerCase() + .includes(needle), + ); +} diff --git a/packages/cli/src/ui/hooks/session-mention-ref.test.ts b/packages/cli/src/ui/hooks/session-mention-ref.test.ts new file mode 100644 index 00000000000..fa997c49b5c --- /dev/null +++ b/packages/cli/src/ui/hooks/session-mention-ref.test.ts @@ -0,0 +1,47 @@ +/** + * @license + * Copyright 2025 Qwen + * SPDX-License-Identifier: Apache-2.0 + */ + +import { describe, it, expect } from 'vitest'; +import { + parseSessionRef, + buildSessionRef, + isSessionId, + SESSION_MENTION_PREFIX, +} from './session-mention-ref.js'; + +const UUID = '3f2504e0-4f89-41d3-9a0c-0305e82c3301'; + +describe('sessionMentionRef', () => { + it('returns null for non-session tokens', () => { + expect(parseSessionRef('file.txt')).toBeNull(); + expect(parseSessionRef('ext:foo')).toBeNull(); + }); + + it('parses a UUID remainder as an id', () => { + expect(parseSessionRef(`${SESSION_MENTION_PREFIX}${UUID}`)).toEqual({ + id: UUID, + }); + }); + + it('parses a non-UUID remainder as a title', () => { + expect(parseSessionRef('session:Fix auth bug')).toEqual({ + title: 'Fix auth bug', + }); + }); + + it('treats an empty remainder as null (lone prefix)', () => { + expect(parseSessionRef('session:')).toBeNull(); + }); + + it('builds a ref without a leading @', () => { + expect(buildSessionRef(UUID)).toBe(`session:${UUID}`); + }); + + it('recognizes UUIDs', () => { + expect(isSessionId(UUID)).toBe(true); + expect(isSessionId('not-a-uuid')).toBe(false); + }); +}); diff --git a/packages/cli/src/ui/hooks/session-mention-ref.ts b/packages/cli/src/ui/hooks/session-mention-ref.ts new file mode 100644 index 00000000000..5b7bb044d31 --- /dev/null +++ b/packages/cli/src/ui/hooks/session-mention-ref.ts @@ -0,0 +1,30 @@ +/** + * @license + * Copyright 2025 Qwen + * SPDX-License-Identifier: Apache-2.0 + */ + +export const SESSION_MENTION_PREFIX = 'session:'; + +const UUID_RE = + /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i; + +export interface SessionRef { + id?: string; + title?: string; +} + +export function isSessionId(value: string): boolean { + return UUID_RE.test(value.trim()); +} + +export function parseSessionRef(pathName: string): SessionRef | null { + if (!pathName.startsWith(SESSION_MENTION_PREFIX)) return null; + const remainder = pathName.slice(SESSION_MENTION_PREFIX.length).trim(); + if (remainder.length === 0) return null; + return isSessionId(remainder) ? { id: remainder } : { title: remainder }; +} + +export function buildSessionRef(idOrTitle: string): string { + return `${SESSION_MENTION_PREFIX}${idOrTitle}`; +} diff --git a/packages/cli/src/ui/hooks/useAtCompletion.test.ts b/packages/cli/src/ui/hooks/useAtCompletion.test.ts index e32ce4229af..a5087f1ced7 100644 --- a/packages/cli/src/ui/hooks/useAtCompletion.test.ts +++ b/packages/cli/src/ui/hooks/useAtCompletion.test.ts @@ -22,6 +22,12 @@ import { import { useState } from 'react'; import type { Suggestion } from '../components/SuggestionsDisplay.js'; +const mockGetSessionSuggestions = vi.hoisted(() => vi.fn()); + +vi.mock('./session-completion.js', () => ({ + getSessionSuggestions: mockGetSessionSuggestions, +})); + // Test harness to capture the state from the hook's callbacks. function useTestHarnessForAtCompletion( enabled: boolean, @@ -58,6 +64,7 @@ describe('useAtCompletion', () => { getFileFilteringEnableFuzzySearch: () => true, } as unknown as Config; vi.clearAllMocks(); + mockGetSessionSuggestions.mockResolvedValue([]); }); afterEach(async () => { @@ -990,6 +997,35 @@ describe('useAtCompletion', () => { expect(values).not.toContain('myserver:'); expect(values).toContain('my-notes.txt'); }); + + it('tags MCP server and resource suggestions with category mcp', async () => { + testRootDir = await createTmpDir({ 'file.txt': '' }); + const resourceConfig = { + ...mockConfig, + getMcpServers: () => ({ myserver: {} }), + getResourceRegistry: () => ({ + getResourcesByServer: (name: string) => + name === 'myserver' + ? [{ uri: 'res://x', name: 'x', serverName: 'myserver' }] + : [], + }), + } as unknown as Config; + + const { result } = renderHook(() => + useTestHarnessForAtCompletion(true, 'my', resourceConfig, testRootDir), + ); + + await waitFor(() => { + expect(result.current.suggestions.length).toBeGreaterThan(0); + }); + const mcpSuggestions = result.current.suggestions.filter( + (s) => s.value === 'mcp:myserver' || s.value === 'myserver:', + ); + expect(mcpSuggestions.length).toBeGreaterThan(0); + for (const s of mcpSuggestions) { + expect(s.category).toBe('mcp'); + } + }); }); describe('Global MCP resource completion', () => { @@ -1153,4 +1189,53 @@ describe('useAtCompletion', () => { ); }); }); + + describe('Session suggestions', () => { + it('merges session suggestions into file search results', async () => { + const structure: FileSystemStructure = { 'file.txt': '' }; + testRootDir = await createTmpDir(structure); + + mockGetSessionSuggestions.mockResolvedValue([ + { + label: 'Fix auth bug', + value: 'session:id-1', + category: 'session', + }, + ]); + + const { result } = renderHook(() => + useTestHarnessForAtCompletion(true, '', mockConfig, testRootDir), + ); + + await waitFor(() => { + expect(result.current.suggestions.length).toBeGreaterThan(0); + }); + + const values = result.current.suggestions.map((s) => s.value); + expect(values).toContain('session:id-1'); + expect(values).toContain('file.txt'); + }); + + it('shows file results when getSessionSuggestions returns empty', async () => { + const structure: FileSystemStructure = { 'file.txt': '' }; + testRootDir = await createTmpDir(structure); + + mockGetSessionSuggestions.mockResolvedValue([]); + + const { result } = renderHook(() => + useTestHarnessForAtCompletion(true, '', mockConfig, testRootDir), + ); + + await waitFor(() => { + expect(result.current.suggestions.length).toBeGreaterThan(0); + }); + + expect(result.current.suggestions.map((s) => s.value)).toContain( + 'file.txt', + ); + expect( + result.current.suggestions.some((s) => s.category === 'session'), + ).toBe(false); + }); + }); }); diff --git a/packages/cli/src/ui/hooks/useAtCompletion.ts b/packages/cli/src/ui/hooks/useAtCompletion.ts index ce9d3b49118..f84a7612243 100644 --- a/packages/cli/src/ui/hooks/useAtCompletion.ts +++ b/packages/cli/src/ui/hooks/useAtCompletion.ts @@ -11,6 +11,7 @@ import type { Suggestion } from '../components/SuggestionsDisplay.js'; import { MAX_SUGGESTIONS_TO_SHOW } from '../components/SuggestionsDisplay.js'; import { matchMcpServerPrefix, buildMcpResourceRef } from './mcpResourceRef.js'; import { getExtensionSuggestions } from './extension-mention-ref.js'; +import { getSessionSuggestions } from './session-completion.js'; import { buildMcpServerRef } from '../../utils/mcp-server-mention.js'; import { t } from '../../i18n/index.js'; @@ -441,16 +442,33 @@ export function useAtCompletion(props: UseAtCompletionProps): void { ...mcpServerMentionSuggestions, ...serverSuggestions, ...globalResourceSuggestions, - ]; + ].map((s) => ({ ...s, category: s.category ?? ('mcp' as const) })); + + // Prior-session suggestions (current project). Kicked off CONCURRENTLY so + // the disk listing never delays the file-search loading timer below; + // awaited only when assembling the final payload. A listing failure + // yields [] so it never blocks file/MCP/extension completion. + // Merge order invariant: sessions are always appended LAST in every + // SEARCH_SUCCESS dispatch path (mcp → file → session). + const sessionPromise = getSessionSuggestions(cwd, state.pattern); if (!fileSearch.current) { - // File index not ready yet; still surface any MCP matches so they + // File index not ready yet; still surface non-file matches so they // don't have to wait on the crawler. - if (mcpSuggestions.length > 0) { + const sessionSuggestions = await sessionPromise.catch( + () => [] as Suggestion[], + ); + // The disk listing awaited above opens a window in which a newer + // keystroke may have superseded this search; drop a stale result. + if (cancelled) { + return; + } + const scoped = [...mcpSuggestions, ...sessionSuggestions]; + if (scoped.length > 0) { if (slowSearchTimer.current) { clearTimeout(slowSearchTimer.current); } - dispatch({ type: 'SEARCH_SUCCESS', payload: mcpSuggestions }); + dispatch({ type: 'SEARCH_SUCCESS', payload: scoped }); } return; } @@ -487,17 +505,35 @@ export function useAtCompletion(props: UseAtCompletionProps): void { label: p, value: escapePath(p), isDirectory: p.endsWith('/'), + category: 'file' as const, })); + const sessionSuggestions = await sessionPromise.catch( + () => [] as Suggestion[], + ); + if (controller.signal.aborted || cancelled) { + return; + } dispatch({ type: 'SEARCH_SUCCESS', - payload: [...mcpSuggestions, ...fileSuggestions], + payload: [ + ...mcpSuggestions, + ...fileSuggestions, + ...sessionSuggestions, + ], }); } catch (error) { if (!(error instanceof Error && error.name === 'AbortError')) { - // A file-search failure shouldn't swallow MCP matches we already + // A file-search failure shouldn't swallow non-file matches we already // have; show those rather than dropping to an error state. - if (mcpSuggestions.length > 0) { - dispatch({ type: 'SEARCH_SUCCESS', payload: mcpSuggestions }); + const sessionSuggestions = await sessionPromise.catch( + () => [] as Suggestion[], + ); + if (cancelled) { + return; + } + const scoped = [...mcpSuggestions, ...sessionSuggestions]; + if (scoped.length > 0) { + dispatch({ type: 'SEARCH_SUCCESS', payload: scoped }); } else { dispatch({ type: 'ERROR' }); } diff --git a/packages/cli/src/ui/hooks/useCommandCompletion.tsx b/packages/cli/src/ui/hooks/useCommandCompletion.tsx index 73723c5517f..38d4d756024 100644 --- a/packages/cli/src/ui/hooks/useCommandCompletion.tsx +++ b/packages/cli/src/ui/hooks/useCommandCompletion.tsx @@ -5,7 +5,10 @@ */ import { useCallback, useMemo, useEffect } from 'react'; -import type { Suggestion } from '../components/SuggestionsDisplay.js'; +import type { + Suggestion, + SuggestionCategory, +} from '../components/SuggestionsDisplay.js'; import type { CommandContext, SlashCommand } from '../commands/types.js'; import type { TextBuffer } from '../components/shared/text-buffer.js'; import { logicalPosToOffset } from '../components/shared/text-buffer.js'; @@ -67,6 +70,12 @@ export interface UseCommandCompletionReturn { acceptText?: string; showCursorBeforeText?: boolean; } | null; + /** Active category tab for the `@` completion UI ('all' shows everything). */ + activeCategory: SuggestionCategory | 'all'; + /** Tabs available for the current suggestion set (always includes 'all'). */ + availableCategories: Array<SuggestionCategory | 'all'>; + /** Cycle the active category tab; resets active/scroll index. */ + switchCategory: (direction: 1 | -1) => void; } export function useCommandCompletion( @@ -202,6 +211,9 @@ export function useCommandCompletion( dismissCompletion, navigateUp, navigateDown, + activeCategory, + availableCategories, + switchCategory, } = useCompletion({ query }); useAtCompletion({ @@ -394,5 +406,8 @@ export function useCommandCompletion( handleAutocomplete, completionMode, midInputGhostText, + activeCategory, + availableCategories, + switchCategory, }; } diff --git a/packages/cli/src/ui/hooks/useCompletion.test.ts b/packages/cli/src/ui/hooks/useCompletion.test.ts index a8ad7eff4f9..3ec6efda302 100644 --- a/packages/cli/src/ui/hooks/useCompletion.test.ts +++ b/packages/cli/src/ui/hooks/useCompletion.test.ts @@ -105,6 +105,7 @@ describe('useCompletion', () => { expect(result.current.isPerfectMatch).toBe(false); expect(result.current.activeSuggestionIndex).toBe(-1); expect(result.current.visibleStartIndex).toBe(0); + expect(result.current.activeCategory).toBe('all'); }); it('does NOT reset skipNextClearRef, preserving the fix for Enter-accept', () => { @@ -233,4 +234,153 @@ describe('useCompletion', () => { expect(result.current.activeSuggestionIndex).toBe(0); }); }); + + describe('category tabs', () => { + const mixed = [ + { label: 'a.ts', value: 'a.ts', category: 'file' as const }, + { label: 'S', value: 'session:1', category: 'session' as const }, + ]; + + it('derives availableCategories from present categories', () => { + const { result } = renderHook(() => useCompletion()); + act(() => { + result.current.setSuggestions(mixed); + }); + expect(result.current.availableCategories).toEqual([ + 'all', + 'file', + 'session', + ]); + expect(result.current.activeCategory).toBe('all'); + }); + + it('keeps a single "all" tab for a single-category set', () => { + const { result } = renderHook(() => useCompletion()); + act(() => { + result.current.setSuggestions([mixed[0]]); + }); + expect(result.current.availableCategories).toEqual(['all']); + }); + + it('cycles the active category and resets the active index', () => { + const { result } = renderHook(() => useCompletion()); + act(() => { + result.current.setSuggestions(mixed); + }); + act(() => result.current.switchCategory(1)); + expect(result.current.activeCategory).toBe('file'); + expect(result.current.activeSuggestionIndex).toBe(0); + }); + + it('cycles backwards with direction -1 and wraps from all to last', () => { + const { result } = renderHook(() => useCompletion()); + act(() => { + result.current.setSuggestions(mixed); + }); + // availableCategories = ['all', 'file', 'session'] + act(() => result.current.switchCategory(-1)); // 'all' -> 'session' (wrap) + expect(result.current.activeCategory).toBe('session'); + expect(result.current.activeSuggestionIndex).toBe(0); + }); + + it('wraps around backwards through all categories', () => { + const { result } = renderHook(() => useCompletion()); + act(() => { + result.current.setSuggestions(mixed); + }); + act(() => result.current.switchCategory(-1)); // 'all' -> 'session' + expect(result.current.activeCategory).toBe('session'); + act(() => result.current.switchCategory(-1)); // 'session' -> 'file' + expect(result.current.activeCategory).toBe('file'); + act(() => result.current.switchCategory(-1)); // 'file' -> 'all' + expect(result.current.activeCategory).toBe('all'); + }); + + it('filters the exposed suggestions to the active category', () => { + const { result } = renderHook(() => useCompletion()); + act(() => { + result.current.setSuggestions(mixed); + }); + act(() => result.current.switchCategory(1)); // → file + expect(result.current.suggestions).toEqual([mixed[0]]); + act(() => result.current.switchCategory(1)); // → session + expect(result.current.suggestions).toEqual([mixed[1]]); + }); + + it('falls back to "all" when the active tab disappears', () => { + const { result } = renderHook(() => useCompletion()); + act(() => { + result.current.setSuggestions(mixed); + }); + act(() => result.current.switchCategory(2 as 1)); // → session + expect(result.current.activeCategory).toBe('session'); + act(() => { + // new set no longer has a session category + result.current.setSuggestions([mixed[0]]); + }); + expect(result.current.activeCategory).toBe('all'); + }); + + it('resets activeSuggestionIndex and visibleStartIndex when the active tab disappears', () => { + const { result } = renderHook(() => useCompletion()); + act(() => { + result.current.setSuggestions(mixed); + }); + act(() => result.current.switchCategory(2 as 1)); // → session + act(() => { + result.current.setActiveSuggestionIndex(3); + result.current.setVisibleStartIndex(2); + }); + expect(result.current.activeSuggestionIndex).toBe(3); + expect(result.current.visibleStartIndex).toBe(2); + + act(() => { + result.current.setSuggestions([mixed[0]]); + }); + expect(result.current.activeCategory).toBe('all'); + expect(result.current.activeSuggestionIndex).toBe(0); + expect(result.current.visibleStartIndex).toBe(0); + }); + + it('clamps activeSuggestionIndex when the suggestion list shrinks', () => { + const { result } = renderHook(() => useCompletion()); + act(() => { + result.current.setSuggestions([ + { label: 'a', value: 'a', category: 'file' as const }, + { label: 'b', value: 'b', category: 'file' as const }, + { label: 'c', value: 'c', category: 'file' as const }, + ]); + result.current.setActiveSuggestionIndex(2); + }); + expect(result.current.activeSuggestionIndex).toBe(2); + + act(() => { + result.current.setSuggestions([ + { label: 'a', value: 'a', category: 'file' as const }, + ]); + }); + expect(result.current.activeSuggestionIndex).toBe(0); + }); + + it('clamps visibleStartIndex when the suggestion list shrinks', () => { + const { result } = renderHook(() => useCompletion()); + const many = Array.from({ length: 12 }, (_, i) => ({ + label: `f${i}`, + value: `f${i}`, + category: 'file' as const, + })); + act(() => { + result.current.setSuggestions(many); + result.current.setVisibleStartIndex(8); + result.current.setActiveSuggestionIndex(10); + }); + expect(result.current.visibleStartIndex).toBe(8); + + act(() => { + result.current.setSuggestions(many.slice(0, 2)); + }); + expect(result.current.activeSuggestionIndex).toBe(1); + expect(result.current.visibleStartIndex).toBe(0); + }); + }); }); diff --git a/packages/cli/src/ui/hooks/useCompletion.ts b/packages/cli/src/ui/hooks/useCompletion.ts index 0b1972c76e0..a808f0c090b 100644 --- a/packages/cli/src/ui/hooks/useCompletion.ts +++ b/packages/cli/src/ui/hooks/useCompletion.ts @@ -4,11 +4,22 @@ * SPDX-License-Identifier: Apache-2.0 */ -import { useState, useCallback, useEffect, useRef } from 'react'; +import { useState, useCallback, useEffect, useMemo, useRef } from 'react'; -import type { Suggestion } from '../components/SuggestionsDisplay.js'; +import type { + Suggestion, + SuggestionCategory, +} from '../components/SuggestionsDisplay.js'; import { MAX_SUGGESTIONS_TO_SHOW } from '../components/SuggestionsDisplay.js'; +/** Fixed display order of category tabs. */ +const CATEGORY_ORDER: SuggestionCategory[] = [ + 'file', + 'session', + 'mcp', + 'extension', +]; + export interface UseCompletionOptions { /** When the completion query changes, the dismissed flag is cleared * (unless dismissCompletion was just called). */ @@ -34,12 +45,24 @@ export interface UseCompletionReturn { resetCompletionState: () => void; navigateUp: () => void; navigateDown: () => void; + /** Active category tab for the `@` completion UI ('all' shows everything). */ + activeCategory: SuggestionCategory | 'all'; + /** Tabs available for the current suggestion set (always includes 'all'). */ + availableCategories: Array<SuggestionCategory | 'all'>; + /** Cycle the active category tab; resets active/scroll index. */ + switchCategory: (direction: 1 | -1) => void; } export function useCompletion( options: UseCompletionOptions = {}, ): UseCompletionReturn { - const [suggestions, setSuggestions] = useState<Suggestion[]>([]); + // Raw, unfiltered suggestions as provided by producers. The publicly exposed + // `suggestions` below is this list filtered to the active category tab, so + // navigation/accept/display all operate on the same visible set. + const [rawSuggestions, setSuggestions] = useState<Suggestion[]>([]); + const [activeCategory, setActiveCategory] = useState< + SuggestionCategory | 'all' + >('all'); const [activeSuggestionIndex, setActiveSuggestionIndex] = useState<number>(-1); const [visibleStartIndex, setVisibleStartIndex] = useState<number>(0); @@ -54,8 +77,70 @@ export function useCompletion( // dismissed in that case. const skipNextClearRef = useRef<boolean>(false); + // Tabs present in the current suggestion set. Only becomes multi-entry when + // more than one category is present (e.g. files + sessions in `@` mode); + // slash/file-only completion keeps a single 'all' entry so the UI hides the + // tab bar and behavior is unchanged. + const availableCategories = useMemo<Array<SuggestionCategory | 'all'>>(() => { + const present = new Set(rawSuggestions.map((s) => s.category ?? 'file')); + const ordered = CATEGORY_ORDER.filter((c) => present.has(c)); + return ordered.length > 1 ? ['all', ...ordered] : ['all']; + }, [rawSuggestions]); + + // The visible suggestion set: the raw list filtered to the active tab. + const suggestions = useMemo<Suggestion[]>( + () => + activeCategory === 'all' + ? rawSuggestions + : rawSuggestions.filter( + (s) => (s.category ?? 'file') === activeCategory, + ), + [rawSuggestions, activeCategory], + ); + + // If the active tab disappears (suggestion set changed), fall back to 'all'. + useEffect(() => { + if (!availableCategories.includes(activeCategory)) { + setActiveCategory('all'); + setActiveSuggestionIndex(0); + setVisibleStartIndex(0); + } + }, [availableCategories, activeCategory]); + + // Clamp the active index when the filtered suggestion list shrinks within + // a still-existing category (e.g. async search returns fewer items). + useEffect(() => { + setActiveSuggestionIndex((prev) => + prev >= suggestions.length && suggestions.length > 0 + ? suggestions.length - 1 + : prev, + ); + setVisibleStartIndex((prev) => + prev >= suggestions.length + ? Math.max(0, suggestions.length - MAX_SUGGESTIONS_TO_SHOW) + : prev, + ); + }, [suggestions.length]); + + const switchCategory = useCallback( + (direction: 1 | -1) => { + setActiveCategory((cur) => { + const idx = availableCategories.indexOf(cur); + if (idx === -1) return 'all'; + const next = + (idx + direction + availableCategories.length) % + availableCategories.length; + return availableCategories[next]; + }); + setActiveSuggestionIndex(0); + setVisibleStartIndex(0); + }, + [availableCategories], + ); + const resetCompletionState = useCallback(() => { setSuggestions([]); + setActiveCategory('all'); setActiveSuggestionIndex(-1); setVisibleStartIndex(0); setShowSuggestions(false); @@ -164,5 +249,8 @@ export function useCompletion( dismissCompletion, navigateUp, navigateDown, + activeCategory, + availableCategories, + switchCategory, }; } diff --git a/packages/cli/src/ui/hooks/useExportCompletion.test.ts b/packages/cli/src/ui/hooks/useExportCompletion.test.ts index e66bfed8f16..6cc980bd756 100644 --- a/packages/cli/src/ui/hooks/useExportCompletion.test.ts +++ b/packages/cli/src/ui/hooks/useExportCompletion.test.ts @@ -89,6 +89,9 @@ function createCompletion( handleAutocomplete: vi.fn(), completionMode: CompletionMode.IDLE, midInputGhostText: null, + activeCategory: 'all', + availableCategories: ['all'], + switchCategory: vi.fn(), ...overrides, }; } diff --git a/packages/cli/src/ui/keyMatchers.test.ts b/packages/cli/src/ui/keyMatchers.test.ts index 289f1980f6d..6932dca55ba 100644 --- a/packages/cli/src/ui/keyMatchers.test.ts +++ b/packages/cli/src/ui/keyMatchers.test.ts @@ -45,6 +45,10 @@ describe('keyMatchers', () => { (key.name === 'up' && !key.shift) || (key.ctrl && key.name === 'p'), [Command.COMPLETION_DOWN]: (key: Key) => (key.name === 'down' && !key.shift) || (key.ctrl && key.name === 'n'), + [Command.COMPLETION_TAB_LEFT]: (key: Key) => + key.name === 'left' && !key.shift && key.ctrl && !key.meta, + [Command.COMPLETION_TAB_RIGHT]: (key: Key) => + key.name === 'right' && !key.shift && key.ctrl && !key.meta, [Command.ESCAPE]: (key: Key) => key.name === 'escape', [Command.SUBMIT]: (key: Key) => key.name === 'return' && !key.ctrl && !key.meta && !key.paste, @@ -232,6 +236,26 @@ describe('keyMatchers', () => { createKey('down', { shift: true }), ], }, + { + command: Command.COMPLETION_TAB_LEFT, + positive: [createKey('left', { ctrl: true })], + negative: [ + createKey('left'), + createKey('left', { shift: true, ctrl: true }), + createKey('left', { ctrl: true, meta: true }), + createKey('right', { ctrl: true }), + ], + }, + { + command: Command.COMPLETION_TAB_RIGHT, + positive: [createKey('right', { ctrl: true })], + negative: [ + createKey('right'), + createKey('right', { shift: true, ctrl: true }), + createKey('right', { ctrl: true, meta: true }), + createKey('left', { ctrl: true }), + ], + }, // Text input { diff --git a/packages/cli/src/ui/utils/suggestions.ts b/packages/cli/src/ui/utils/suggestions.ts index c38cc6c4ebd..6d7b5151a9a 100644 --- a/packages/cli/src/ui/utils/suggestions.ts +++ b/packages/cli/src/ui/utils/suggestions.ts @@ -10,6 +10,9 @@ import type { ExecutionMode, } from '../commands/types.js'; +/** Grouping category for the tabbed `@` completion UI. */ +export type SuggestionCategory = 'file' | 'session' | 'mcp' | 'extension'; + export interface Suggestion { label: string; value: string; @@ -24,6 +27,8 @@ export interface Suggestion { matchedAlias?: string; supportedModes?: ExecutionMode[]; modelInvocable?: boolean; + /** Grouping category for the tabbed `@` completion UI. Defaults to 'file'. */ + category?: SuggestionCategory; /** Whether the suggestion represents a directory path. When true, handleAutocomplete should NOT append a trailing space so the user can continue tab-completing deeper into the directory tree. */ isDirectory?: boolean; /** diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index e642183cb63..b9a3dfee848 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -261,6 +261,7 @@ export * from './services/visionBridge/image-part-utils.js'; export * from './services/visionBridge/image-capability.js'; export * from './services/sessionRecap.js'; export * from './services/session-artifact-persistence.js'; +export * from './services/session-reference-service.js'; export * from './services/sessionService.js'; export * from './services/session-writer-lease.js'; export { diff --git a/packages/core/src/services/session-reference-service.test.ts b/packages/core/src/services/session-reference-service.test.ts new file mode 100644 index 00000000000..f98c76b0f20 --- /dev/null +++ b/packages/core/src/services/session-reference-service.test.ts @@ -0,0 +1,461 @@ +/** + * @license + * Copyright 2025 Qwen + * SPDX-License-Identifier: Apache-2.0 + */ + +import { describe, it, expect, vi } from 'vitest'; +import { SessionReferenceService } from './session-reference-service.js'; +import type { ResumedSessionData } from './sessionService.js'; + +function fakeResumed(messages: unknown[]): ResumedSessionData { + return { + conversation: { + sessionId: 's1', + projectHash: 'h', + startTime: '', + lastUpdated: '', + messages: messages as never, + }, + filePath: '/tmp/s1.jsonl', + lastCompletedUuid: null, + } as ResumedSessionData; +} + +function makeSvc(resumed: ResumedSessionData | undefined) { + const svc = new SessionReferenceService('/proj'); + (svc as unknown as { loadSession: () => Promise<unknown> }).loadSession = vi + .fn() + .mockResolvedValue(resumed); + return svc; +} + +describe('SessionReferenceService', () => { + it('returns notFound when session is missing', async () => { + const svc = makeSvc(undefined); + expect(await svc.resolve('missing')).toEqual({ notFound: true }); + }); + + it('keeps user + assistant text and drops thoughts', async () => { + const svc = makeSvc( + fakeResumed([ + { type: 'user', message: { role: 'user', parts: [{ text: 'hi' }] } }, + { + type: 'assistant', + message: { + role: 'model', + parts: [{ thought: true, text: 'reason' }, { text: 'hello' }], + }, + }, + ]), + ); + const res = await svc.resolve('s1'); + if ('notFound' in res) throw new Error('unexpected'); + expect(res.text).toContain('User: hi'); + expect(res.text).toContain('Assistant: hello'); + expect(res.text).not.toContain('reason'); + }); + + it('collapses tool calls to one-line summaries without result bodies', async () => { + const svc = makeSvc( + fakeResumed([ + { + type: 'tool_result', + toolCallResult: { callId: 'c1' }, + message: { + role: 'user', + parts: [ + { + functionResponse: { + name: 'read_file', + response: { huge: 'BODY' }, + }, + }, + ], + }, + }, + ]), + ); + const res = await svc.resolve('s1'); + if ('notFound' in res) throw new Error('unexpected'); + expect(res.text).toContain('[tool: read_file — ok]'); + expect(res.text).not.toContain('BODY'); + }); + + it('marks a failed tool call as error', async () => { + const svc = makeSvc( + fakeResumed([ + { + type: 'tool_result', + toolCallResult: { callId: 'c1', error: new Error('boom') }, + message: { + role: 'user', + parts: [{ functionResponse: { name: 'write_file', response: {} } }], + }, + }, + ]), + ); + const res = await svc.resolve('s1'); + if ('notFound' in res) throw new Error('unexpected'); + expect(res.text).toContain('[tool: write_file — error]'); + }); + + it('marks a cancelled tool call as cancelled, not ok', async () => { + const svc = makeSvc( + fakeResumed([ + { + type: 'tool_result', + toolCallResult: { callId: 'c1', status: 'cancelled' }, + message: { + role: 'user', + parts: [{ functionResponse: { name: 'read_file', response: {} } }], + }, + }, + ]), + ); + const res = await svc.resolve('s1'); + if ('notFound' in res) throw new Error('unexpected'); + expect(res.text).toContain('[tool: read_file — cancelled]'); + expect(res.text).not.toContain('[tool: read_file — ok]'); + }); + + it('maps a successful tool call status to the ok display label', async () => { + const svc = makeSvc( + fakeResumed([ + { + type: 'tool_result', + toolCallResult: { callId: 'c1', status: 'success' }, + message: { + role: 'user', + parts: [{ functionResponse: { name: 'read_file', response: {} } }], + }, + }, + ]), + ); + const res = await svc.resolve('s1'); + if ('notFound' in res) throw new Error('unexpected'); + expect(res.text).toContain('[tool: read_file — ok]'); + expect(res.text).not.toContain('success'); + }); + + it('surfaces an error tool_result that has no functionResponse parts', async () => { + const svc = makeSvc( + fakeResumed([ + { + type: 'tool_result', + toolCallResult: { + callId: 'c1', + error: new Error('permission denied'), + }, + message: { + role: 'user', + parts: [], + }, + }, + ]), + ); + const res = await svc.resolve('s1'); + if ('notFound' in res) throw new Error('unexpected'); + // No functionResponse names to derive a tool line from; the record + // contributes nothing to the slimmed output. Verify it does not throw + // and the session still resolves. + expect(res.text).toContain('Referenced session'); + }); + + it('keeps assistant text on a turn that ALSO calls a tool', async () => { + // An assistant turn that calls a tool is a SINGLE record carrying both the + // text and the functionCall parts; the paired tool_result carries the + // response. The assistant preamble must not be dropped. + const svc = makeSvc( + fakeResumed([ + { + type: 'assistant', + message: { + role: 'model', + parts: [ + { text: "I'll read the config to check X" }, + { functionCall: { name: 'read_file', args: {} } }, + ], + }, + }, + { + type: 'tool_result', + toolCallResult: { callId: 'c1' }, + message: { + role: 'user', + parts: [ + { + functionResponse: { + name: 'read_file', + response: { huge: 'BODY' }, + }, + }, + ], + }, + }, + ]), + ); + const res = await svc.resolve('s1'); + if ('notFound' in res) throw new Error('unexpected'); + expect(res.text).toContain("Assistant: I'll read the config to check X"); + // exactly one tool line (from the response side), not duplicated + expect(res.text.match(/\[tool: read_file — ok\]/g)).toHaveLength(1); + expect(res.text).not.toContain('BODY'); + }); + + it('emits one tool line per parallel tool call in a single turn', async () => { + const svc = makeSvc( + fakeResumed([ + { + type: 'tool_result', + toolCallResult: { callId: 'c1' }, + message: { + role: 'user', + parts: [ + { functionResponse: { name: 'read_file', response: {} } }, + { functionResponse: { name: 'grep', response: {} } }, + ], + }, + }, + ]), + ); + const res = await svc.resolve('s1'); + if ('notFound' in res) throw new Error('unexpected'); + expect(res.text).toContain('[tool: read_file — ok]'); + expect(res.text).toContain('[tool: grep — ok]'); + }); + + it('retains the newest turn even when it alone exceeds the budget', async () => { + const svc = makeSvc( + fakeResumed([ + { + type: 'user', + message: { role: 'user', parts: [{ text: 'old turn' }] }, + }, + { + type: 'assistant', + message: { + role: 'model', + parts: [{ text: 'huge newest turn ' + 'y'.repeat(4000) }], + }, + }, + ]), + ); + const res = await svc.resolve('s1', { budgetTokens: 50, title: 's1' }); + if ('notFound' in res) throw new Error('unexpected'); + expect(res.truncated).toBe(true); + expect(res.text).toContain('[earlier turns omitted]'); + // the newest turn is still present, not collapsed to just the marker + expect(res.text).toContain('huge newest turn'); + expect(res.text).not.toContain('old turn'); + }); + + it('tail-trims to budget and marks truncated', async () => { + const many = Array.from({ length: 50 }, (_, i) => ({ + type: 'user', + message: { + role: 'user', + parts: [{ text: `turn ${i} ` + 'x'.repeat(400) }], + }, + })); + const svc = makeSvc(fakeResumed(many)); + const res = await svc.resolve('s1', { budgetTokens: 200, title: 's1' }); + if ('notFound' in res) throw new Error('unexpected'); + expect(res.truncated).toBe(true); + expect(res.text).toContain('[earlier turns omitted]'); + expect(res.text).toContain('turn 49'); // newest retained + expect(res.text).not.toContain('turn 0 '); // oldest dropped + }); + + it('emits a placeholder when there is no textual content', async () => { + const svc = makeSvc( + fakeResumed([ + { type: 'system', subtype: 'custom_title', message: undefined }, + ]), + ); + const res = await svc.resolve('s1'); + if ('notFound' in res) throw new Error('unexpected'); + expect(res.text).toContain('(no textual content)'); + expect(res.truncated).toBe(false); + }); + + it('includes header overhead in approxTokens', async () => { + const svc = makeSvc( + fakeResumed([ + { + type: 'user', + message: { role: 'user', parts: [{ text: 'hello' }] }, + }, + ]), + ); + const res = await svc.resolve('s1', { title: 'Test' }); + if ('notFound' in res) throw new Error('unexpected'); + // approxTokens must account for the header overhead, not just the body. + const bodyOnly = svc['estimate'](['User: hello']); + expect(res.meta.approxTokens).toBeGreaterThan(bodyOnly); + }); + + it('excludes omission marker cost from approxTokens when not truncated', async () => { + const svc = makeSvc( + fakeResumed([ + { + type: 'user', + message: { role: 'user', parts: [{ text: 'hello' }] }, + }, + ]), + ); + const res = await svc.resolve('s1', { title: 'Test' }); + if ('notFound' in res) throw new Error('unexpected'); + expect(res.truncated).toBe(false); + const header = '--- Referenced session "Test" (slimmed, read-only) ---'; + const expected = + svc['estimate'](['User: hello']) + svc['estimate']([header]); + expect(res.meta.approxTokens).toBe(expected); + }); + + it('includes omission marker cost in approxTokens when truncated', async () => { + const many = Array.from({ length: 50 }, (_, i) => ({ + type: 'user', + message: { + role: 'user', + parts: [{ text: `turn ${i} ` + 'x'.repeat(400) }], + }, + })); + const svc = makeSvc(fakeResumed(many)); + const res = await svc.resolve('s1', { budgetTokens: 200, title: 's1' }); + if ('notFound' in res) throw new Error('unexpected'); + expect(res.truncated).toBe(true); + const header = '--- Referenced session "s1" (slimmed, read-only) ---'; + const overhead = svc['estimate']([header, '[earlier turns omitted]']); + const kept = res.text + .replace(header + '\n', '') + .replace('[earlier turns omitted]\n', '') + .split('\n'); + expect(res.meta.approxTokens).toBe(svc['estimate'](kept) + overhead); + }); +}); + +describe('title derivation', () => { + it('derives title from first user message when no explicit title given', async () => { + const svc = makeSvc( + fakeResumed([ + { + type: 'user', + message: { role: 'user', parts: [{ text: 'Fix the auth bug' }] }, + }, + { + type: 'assistant', + message: { role: 'model', parts: [{ text: 'Sure' }] }, + }, + ]), + ); + const res = await svc.resolve('s1'); + if ('notFound' in res) throw new Error('unexpected'); + expect(res.meta.title).toBe('Fix the auth bug'); + expect(res.text).toContain('Referenced session "Fix the auth bug"'); + }); + + it('prefers a custom_title system record over the first user message', async () => { + const svc = makeSvc( + fakeResumed([ + { + type: 'system', + subtype: 'custom_title', + systemPayload: { customTitle: 'Auth bug investigation' }, + message: undefined, + }, + { + type: 'user', + message: { role: 'user', parts: [{ text: 'Fix the auth bug' }] }, + }, + ]), + ); + const res = await svc.resolve('s1'); + if ('notFound' in res) throw new Error('unexpected'); + expect(res.meta.title).toBe('Auth bug investigation'); + }); + + it('uses the last custom_title when a session has been renamed', async () => { + const svc = makeSvc( + fakeResumed([ + { + type: 'system', + subtype: 'custom_title', + systemPayload: { customTitle: 'Fix the auth bug' }, + message: undefined, + }, + { + type: 'user', + message: { role: 'user', parts: [{ text: 'hello' }] }, + }, + { + type: 'system', + subtype: 'custom_title', + systemPayload: { customTitle: 'Auth investigation' }, + message: undefined, + }, + ]), + ); + const res = await svc.resolve('s1'); + if ('notFound' in res) throw new Error('unexpected'); + expect(res.meta.title).toBe('Auth investigation'); + }); + + it('truncates a long first user message to 80 chars', async () => { + const long = 'A'.repeat(120); + const svc = makeSvc( + fakeResumed([ + { + type: 'user', + message: { role: 'user', parts: [{ text: long }] }, + }, + ]), + ); + const res = await svc.resolve('s1'); + if ('notFound' in res) throw new Error('unexpected'); + expect(res.meta.title).toHaveLength(80); + expect(res.meta.title.endsWith('...')).toBe(true); + }); + + it('uses only the first line of a multi-line user message', async () => { + const svc = makeSvc( + fakeResumed([ + { + type: 'user', + message: { + role: 'user', + parts: [{ text: 'Short title\nLonger body text' }], + }, + }, + ]), + ); + const res = await svc.resolve('s1'); + if ('notFound' in res) throw new Error('unexpected'); + expect(res.meta.title).toBe('Short title'); + }); + + it('falls back to sessionId when there are no user messages', async () => { + const svc = makeSvc( + fakeResumed([ + { type: 'system', subtype: 'custom_title', message: undefined }, + ]), + ); + const res = await svc.resolve('s1'); + if ('notFound' in res) throw new Error('unexpected'); + expect(res.meta.title).toBe('s1'); + }); + + it('prefers an explicit title over derivation', async () => { + const svc = makeSvc( + fakeResumed([ + { + type: 'user', + message: { role: 'user', parts: [{ text: 'First message' }] }, + }, + ]), + ); + const res = await svc.resolve('s1', { title: 'Custom Title' }); + if ('notFound' in res) throw new Error('unexpected'); + expect(res.meta.title).toBe('Custom Title'); + }); +}); diff --git a/packages/core/src/services/session-reference-service.ts b/packages/core/src/services/session-reference-service.ts new file mode 100644 index 00000000000..31998154729 --- /dev/null +++ b/packages/core/src/services/session-reference-service.ts @@ -0,0 +1,210 @@ +/** + * @license + * Copyright 2025 Qwen + * SPDX-License-Identifier: Apache-2.0 + */ + +import type { Content, Part } from '@google/genai'; +import { SessionService } from './sessionService.js'; +import type { ChatRecord } from './chatRecordingService.js'; +import { estimateContentTokens } from './tokenEstimation.js'; + +/** Default token budget for an injected slimmed session reference. */ +export const SESSION_REF_TOKEN_BUDGET = 8000; + +export interface SlimmedSessionReference { + /** Labeled, budget-trimmed block ready to inject as a text part. */ + text: string; + meta: { + sessionId: string; + title: string; + messageCount: number; + approxTokens: number; + }; + /** True when older turns were dropped to fit the budget. */ + truncated: boolean; +} + +interface FunctionResponsePart { + functionResponse?: { name?: string }; +} + +interface ThoughtPart { + thought?: boolean; +} + +interface CustomTitlePayload { + customTitle?: string; +} + +/** + * Loads a prior chat session and turns it into a deterministically slimmed, + * read-only text block suitable for injecting into the current context as + * reference material. No model/LLM call is made — slimming is purely + * mechanical. + * + * Slimming rules: + * - user / assistant visible text is kept (thoughts dropped), including the + * preamble on an assistant turn that also calls a tool; + * - each tool call collapses to a single line `[tool: <name> — <status>]` + * (never the tool result body), derived from the response side; + * - the joined transcript is tail-retained to a fixed token budget, dropping + * the oldest turns first but always keeping at least the newest line. + */ +export class SessionReferenceService { + private readonly sessionService: SessionService; + + constructor(cwd: string) { + this.sessionService = new SessionService(cwd); + } + + // Indirection kept as an instance method so tests can stub it. + protected loadSession(sessionId: string) { + return this.sessionService.loadSession(sessionId); + } + + async resolve( + sessionId: string, + opts: { budgetTokens?: number; title?: string } = {}, + ): Promise<SlimmedSessionReference | { notFound: true }> { + const resumed = await this.loadSession(sessionId); + if (!resumed) return { notFound: true }; + + const records = resumed.conversation.messages ?? []; + const lines = this.recordsToLines(records); + const budget = opts.budgetTokens ?? SESSION_REF_TOKEN_BUDGET; + + const title = opts.title ?? this.deriveTitle(records) ?? sessionId; + const header = `--- Referenced session "${title}" (slimmed, read-only) ---`; + const headerCost = this.estimate([header]); + + // Single-pass tail-retention: estimate each line once, then accumulate + // from the newest line backward until the budget is reached. Avoids the + // O(N²) cost of re-joining and re-scanning all remaining lines per + // dropped line (which dominated resolve() time for long sessions). + const perLine = lines.map((l) => this.estimate([l])); + let total = headerCost; + let start = lines.length; + while (start > 0 && total + perLine[start - 1] <= budget) { + total += perLine[start - 1]; + start--; + } + // Always keep at least the newest line so an over-budget final turn + // still yields content (rather than collapsing to just the marker). + if (start === lines.length && lines.length > 0) { + start = lines.length - 1; + } + const kept = lines.slice(start); + const truncated = start > 0; + const overhead = truncated + ? headerCost + this.estimate(['[earlier turns omitted]']) + : headerCost; + + const body = + (truncated ? '[earlier turns omitted]\n' : '') + kept.join('\n'); + const text = + body.trim().length === 0 + ? `${header}\n(no textual content)` + : `${header}\n${body}`; + + return { + text, + meta: { + sessionId, + title, + messageCount: records.length, + approxTokens: this.estimate(kept) + overhead, + }, + truncated, + }; + } + + private estimate(lines: string[]): number { + const contents: Content[] = [ + { role: 'user', parts: [{ text: lines.join('\n') }] }, + ]; + return estimateContentTokens(contents); + } + + private recordsToLines(records: ChatRecord[]): string[] { + const out: string[] = []; + for (const rec of records) { + // User / assistant visible text. An assistant turn that also calls tools + // is a SINGLE record carrying both the text and the functionCall parts, + // so we must emit its text here rather than short-circuiting on the tool + // parts (which would silently drop the assistant's reasoning). + if (rec.type === 'user') { + const text = this.visibleText(rec.message); + if (text) out.push(`User: ${text}`); + } else if (rec.type === 'assistant') { + const text = this.visibleText(rec.message); + if (text) out.push(`Assistant: ${text}`); + } + + // Tool summaries: derived ONLY from the response side (functionResponse), + // which carries the accurate status via `toolCallResult`. The call side + // (functionCall on the assistant record) is intentionally NOT summarized + // to avoid a duplicate, always-"ok" line. Never includes result bodies. + for (const name of this.functionResponseNames(rec.message)) { + const raw = rec.toolCallResult?.status; + const status = rec.toolCallResult?.error + ? 'error' + : raw === 'success' + ? 'ok' + : (raw ?? 'ok'); + out.push(`[tool: ${name} — ${status}]`); + } + // system records contribute nothing + } + return out; + } + + private visibleText(message?: Content): string { + if (!message?.parts) return ''; + return message.parts + .filter((p: Part) => !(p as ThoughtPart).thought && p.text) + .map((p: Part) => p.text) + .join('') + .trim(); + } + + private static readonly TITLE_MAX_LENGTH = 80; + + private deriveTitle(records: ChatRecord[]): string | undefined { + // Prefer the user's explicitly set session title (custom_title system + // record) over the first user message, so a renamed session shows its + // chosen name rather than the original prompt. Last-write-wins: each + // rename appends a new record, so the last one is the current title. + let customTitle: string | undefined; + for (const rec of records) { + if (rec.type === 'system' && rec.subtype === 'custom_title') { + const payload = rec.systemPayload as CustomTitlePayload | undefined; + if (payload?.customTitle) customTitle = payload.customTitle; + } + } + if (customTitle) return customTitle; + for (const rec of records) { + if (rec.type !== 'user') continue; + const text = this.visibleText(rec.message); + if (!text) continue; + const firstLine = text.split('\n')[0].trim(); + if (firstLine.length === 0) continue; + return firstLine.length > SessionReferenceService.TITLE_MAX_LENGTH + ? firstLine.slice(0, SessionReferenceService.TITLE_MAX_LENGTH - 3) + + '...' + : firstLine; + } + return undefined; + } + + /** Names of every `functionResponse` part in a record (parallel tool calls + * each yield their own line; a single tool call yields one). */ + private functionResponseNames(message?: Content): string[] { + if (!message?.parts) return []; + return message.parts + .map((p: Part) => (p as FunctionResponsePart).functionResponse?.name) + .filter( + (name): name is string => typeof name === 'string' && name.length > 0, + ); + } +}