From 821979e2cdeb8b97912b9bcb1cea759c724b925e Mon Sep 17 00:00:00 2001 From: LaZzyMan Date: Fri, 17 Jul 2026 10:50:43 +0800 Subject: [PATCH 01/34] docs: design spec for @ session reference + tabbed completion UI --- .../2026-07-17-at-session-reference-design.md | 188 ++++++++++++++++++ 1 file changed, 188 insertions(+) create mode 100644 docs/superpowers/specs/2026-07-17-at-session-reference-design.md 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..a08ee535153 --- /dev/null +++ b/docs/superpowers/specs/2026-07-17-at-session-reference-design.md @@ -0,0 +1,188 @@ +# `@` Session Reference + Tabbed Completion UI — Design + +Date: 2026-07-17 +Branch: `lazzy/at-session-ref` +Status: Proposed + +## 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. +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` = relative time / message + count. 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. Tab counts shown per tab. + 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). From 8a6c19d23896493a0a950ae8a7b09b1edc6fe5b8 Mon Sep 17 00:00:00 2001 From: LaZzyMan <zeusdream7@gmail.com> Date: Fri, 17 Jul 2026 11:18:06 +0800 Subject: [PATCH 02/34] docs: implementation plan for @ session reference + tabbed completion UI --- .../plans/2026-07-17-at-session-reference.md | 1019 +++++++++++++++++ 1 file changed, 1019 insertions(+) create mode 100644 docs/superpowers/plans/2026-07-17-at-session-reference.md 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..a6b08ec26be --- /dev/null +++ b/docs/superpowers/plans/2026-07-17-at-session-reference.md @@ -0,0 +1,1019 @@ +# `@` 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 (`sessionMentionRef`); 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: <name> — <status>]`); 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: `sessionMentionRef` — parse/build/validate `@session:` refs + +**Files:** +- Create: `packages/cli/src/ui/hooks/sessionMentionRef.ts` +- Test: `packages/cli/src/ui/hooks/sessionMentionRef.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:<idOrTitle>` (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/sessionMentionRef.test.ts +import { describe, it, expect } from 'vitest'; +import { + parseSessionRef, + buildSessionRef, + isSessionId, + SESSION_MENTION_PREFIX, +} from './sessionMentionRef.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/sessionMentionRef.test.ts` +Expected: FAIL — `Cannot find module './sessionMentionRef.js'`. + +- [ ] **Step 3: Write minimal implementation** + +```ts +// packages/cli/src/ui/hooks/sessionMentionRef.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/sessionMentionRef.test.ts` +Expected: PASS (6 tests). + +- [ ] **Step 5: Commit** + +```bash +git add packages/cli/src/ui/hooks/sessionMentionRef.ts packages/cli/src/ui/hooks/sessionMentionRef.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/sessionReferenceService.ts` +- Test: `packages/core/src/services/sessionReferenceService.test.ts` +- Modify (export barrel): `packages/core/src/index.ts` (add `export * from './services/sessionReferenceService.js';` alongside existing service exports) + +**Interfaces:** +- Consumes: `SessionService.loadSession(id): Promise<ResumedSessionData | undefined>` (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(ref: { id?: string; title?: string }, opts?: { budgetTokens?: number }): Promise<SlimmedSessionReference | { notFound: true } | { ambiguous: true; count: number }> }` + +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: <displayName || name> — <status || 'ok'>]`. 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`. + +Revised Produces (authoritative): +```ts +resolve(sessionId: string, opts?: { budgetTokens?: number }): + Promise<SlimmedSessionReference | { notFound: true }> +``` + +- [ ] **Step 1: Write the failing test** + +```ts +// packages/core/src/services/sessionReferenceService.test.ts +import { describe, it, expect, vi } from 'vitest'; +import { SessionReferenceService } from './sessionReferenceService.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<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: { 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'); + }); + + 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/sessionReferenceService.test.ts` +Expected: FAIL — `Cannot find module './sessionReferenceService.js'`. + +- [ ] **Step 3: Write minimal implementation** + +```ts +// packages/core/src/services/sessionReferenceService.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<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; + + 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 = + resumed.conversation.messages.length > 0 + ? sessionId // caller supplies a friendlier title in Task 3; id fallback here + : sessionId; + 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}]`); + continue; + } + 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; + } + + 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 + ); + } + + 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/sessionReferenceService.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/sessionReferenceService.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/sessionReferenceService.ts packages/core/src/services/sessionReferenceService.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<SessionListItem[]>`. +- 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). + +- [ ] **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<string, unknown>; + 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 './sessionMentionRef.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) { + 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 + } + } + 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; +} +``` +(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/sessionCompletion.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/sessionCompletion.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<Suggestion[]>` + +- [ ] **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/sessionCompletion.test.ts +import { describe, it, expect, vi } from 'vitest'; + +vi.mock('@qwen-code/qwen-code-core', async (orig) => { + const actual = (await orig()) as Record<string, unknown>; + 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 './sessionCompletion.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/sessionCompletion.test.ts` +Expected: FAIL — `Cannot find module './sessionCompletion.js'`. + +- [ ] **Step 3: Implement the producer** + +```ts +// packages/cli/src/ui/hooks/sessionCompletion.ts +import { SessionService } from '@qwen-code/qwen-code-core'; +import type { Suggestion } from '../components/SuggestionsDisplay.js'; +import { buildSessionRef } from './sessionMentionRef.js'; + +const MAX_SESSION_SUGGESTIONS = 20; + +export async function getSessionSuggestions( + cwd: string, + pattern: string, +): Promise<Suggestion[]> { + 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 needle = pattern.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/sessionCompletion.test.ts` +Expected: PASS (2 tests). + +- [ ] **Step 5: Wire into `useAtCompletion.ts`** + +- Add import: `import { getSessionSuggestions } from './sessionCompletion.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/sessionCompletion.ts packages/cli/src/ui/hooks/sessionCompletion.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<SuggestionCategory | 'all'>`. 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( + <SuggestionsDisplay + suggestions={suggestions} + 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={suggestions} + 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={[suggestions[0]]} + activeIndex={0} + isLoading={false} + width={80} + scrollOffset={0} + userInput="" + mode="reverse" + activeCategory="all" + availableCategories={['all', 'file']} + />, + ); + 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<SuggestionCategory | 'all'>; +``` +- Add a label map: +```ts +const CATEGORY_LABEL: Record<SuggestionCategory | 'all', string> = { + 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. +- Render a tab bar (mirror `StatsTabs` in `StatsDialog.tsx`) above the list, only when `(availableCategories?.length ?? 0) > 2`: +```tsx +{(availableCategories?.length ?? 0) > 2 && ( + <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} + > + {` ${CATEGORY_LABEL[cat]} `} + </Text> + </Box> + ); + })} + <Box marginLeft={2}> + <Text color={theme.text.secondary}>(←/→ to switch)</Text> + </Box> + </Box> +)} +``` + +- [ ] **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<SuggestionCategory | 'all'>`, `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<Array<SuggestionCategory | 'all'>>(() => { + 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] : 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]); +``` +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:<id>`. +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:<garbage-uuid>` 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. From d03b3382057e4d0cd4d6dab836d5f5f182355447 Mon Sep 17 00:00:00 2001 From: LaZzyMan <zeusdream7@gmail.com> Date: Fri, 17 Jul 2026 15:54:55 +0800 Subject: [PATCH 03/34] feat(cli): add @session: mention ref parser --- .../src/ui/hooks/session-mention-ref.test.ts | 41 +++++++++++++++++++ .../cli/src/ui/hooks/session-mention-ref.ts | 30 ++++++++++++++ 2 files changed, 71 insertions(+) create mode 100644 packages/cli/src/ui/hooks/session-mention-ref.test.ts create mode 100644 packages/cli/src/ui/hooks/session-mention-ref.ts 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..9a55a1476df --- /dev/null +++ b/packages/cli/src/ui/hooks/session-mention-ref.test.ts @@ -0,0 +1,41 @@ +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}`; +} From c8c67af1bc28c268f32d0a7b2480d2b9ed15583e Mon Sep 17 00:00:00 2001 From: LaZzyMan <zeusdream7@gmail.com> Date: Fri, 17 Jul 2026 15:57:33 +0800 Subject: [PATCH 04/34] feat(core): add SessionReferenceService for slimmed session injection --- packages/core/src/index.ts | 1 + .../session-reference-service.test.ts | 131 +++++++++++++++ .../src/services/session-reference-service.ts | 156 ++++++++++++++++++ 3 files changed, 288 insertions(+) create mode 100644 packages/core/src/services/session-reference-service.test.ts create mode 100644 packages/core/src/services/session-reference-service.ts diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 61bba145063..57b55c001e4 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -233,6 +233,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 { decodeSessionTranscriptCursor, 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..d67a9e78286 --- /dev/null +++ b/packages/core/src/services/session-reference-service.test.ts @@ -0,0 +1,131 @@ +/** + * @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('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 + }); + + 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); + }); +}); 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..0edcf77ccd6 --- /dev/null +++ b/packages/core/src/services/session-reference-service.ts @@ -0,0 +1,156 @@ +/** + * @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 FunctionCallPart { + functionCall?: { name?: string }; + functionResponse?: { name?: string }; +} + +interface ThoughtPart { + thought?: boolean; +} + +/** + * 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); + * - each tool call collapses to a single line `[tool: <name> — <status>]` + * (never the tool result body); + * - the joined transcript is tail-retained to a fixed token budget, dropping + * the oldest turns first. + */ +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 kept = [...lines]; + let truncated = false; + while (kept.length > 0 && this.estimate(kept) > budget) { + kept.shift(); // drop oldest first (tail-retention) + truncated = true; + } + + const title = opts.title ?? sessionId; + const header = `--- Referenced session "${title}" (slimmed, read-only) ---`; + 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), + }, + 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 = this.functionName(rec.message) ?? 'tool'; + const status = rec.toolCallResult?.error ? 'error' : 'ok'; + out.push(`[tool: ${name} — ${status}]`); + continue; + } + 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; + } + + 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 hasFunctionPart(message?: Content): boolean { + return ( + message?.parts?.some( + (p: Part) => + (p as FunctionCallPart).functionCall || + (p as FunctionCallPart).functionResponse, + ) ?? false + ); + } + + private functionName(message?: Content): string | undefined { + const p = message?.parts?.find( + (x: Part) => + (x as FunctionCallPart).functionCall || + (x as FunctionCallPart).functionResponse, + ) as FunctionCallPart | undefined; + return p?.functionCall?.name ?? p?.functionResponse?.name; + } +} From 5fe3554af0a9b1d243018c51a689a6ed776c9dc5 Mon Sep 17 00:00:00 2001 From: LaZzyMan <zeusdream7@gmail.com> Date: Fri, 17 Jul 2026 16:03:23 +0800 Subject: [PATCH 05/34] feat(cli): inject slimmed prior-session context on @session: mention --- .../hooks/atCommandProcessor.session.test.ts | 176 ++++++++++++++++++ .../cli/src/ui/hooks/atCommandProcessor.ts | 103 +++++++++- 2 files changed, 278 insertions(+), 1 deletion(-) create mode 100644 packages/cli/src/ui/hooks/atCommandProcessor.session.test.ts 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..6fd9752b3ac --- /dev/null +++ b/packages/cli/src/ui/hooks/atCommandProcessor.session.test.ts @@ -0,0 +1,176 @@ +/** + * @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 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 !== undefined, + ), + ).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', + ); + }); +}); diff --git a/packages/cli/src/ui/hooks/atCommandProcessor.ts b/packages/cli/src/ui/hooks/atCommandProcessor.ts index 88f3b8123f2..2194e2f512d 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, @@ -232,6 +235,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 "@" @@ -306,6 +317,17 @@ export async function resolveAtCommandQuery({ continue; } + // Session reference (`@session:<id|title>`): detected BEFORE filesystem + // resolution so the ':' in the token isn't mistaken for a path. 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) { + sessionMentions.push({ originalAtPath, ref: sessionRef }); + atPathToResolvedSpecMap.set(originalAtPath, pathName); + continue; + } + // Check if path should be ignored based on filtering options const workspaceContext = config.getWorkspaceContext(); @@ -526,7 +548,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() === '@') { @@ -600,6 +623,84 @@ 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. + 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) { + const matches = await new SessionService( + config.getProjectRoot(), + ).findSessionsByTitle(ref.title); + 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; + } + } + + const resolved = await new SessionReferenceService( + config.getProjectRoot(), + ).resolve(sessionId!, ref.title ? { title: ref.title } : {}); + + 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]), ); From fea01619225d4aa2152489e6e9152a141afa9b95 Mon Sep 17 00:00:00 2001 From: LaZzyMan <zeusdream7@gmail.com> Date: Fri, 17 Jul 2026 16:16:37 +0800 Subject: [PATCH 06/34] feat(cli): surface prior sessions as @ completion suggestions --- .../src/ui/components/SuggestionsDisplay.tsx | 6 ++ .../cli/src/ui/hooks/extension-mention-ref.ts | 1 + .../src/ui/hooks/session-completion.test.ts | 81 +++++++++++++++++++ .../cli/src/ui/hooks/session-completion.ts | 54 +++++++++++++ packages/cli/src/ui/hooks/useAtCompletion.ts | 36 +++++++-- 5 files changed, 170 insertions(+), 8 deletions(-) create mode 100644 packages/cli/src/ui/hooks/session-completion.test.ts create mode 100644 packages/cli/src/ui/hooks/session-completion.ts diff --git a/packages/cli/src/ui/components/SuggestionsDisplay.tsx b/packages/cli/src/ui/components/SuggestionsDisplay.tsx index 6d84e6c8a44..742330b89d8 100644 --- a/packages/cli/src/ui/components/SuggestionsDisplay.tsx +++ b/packages/cli/src/ui/components/SuggestionsDisplay.tsx @@ -16,6 +16,10 @@ import type { } from '../commands/types.js'; import { Colors } from '../colors.js'; import { t } from '../../i18n/index.js'; + +/** Grouping category for the tabbed `@` completion UI. */ +export type SuggestionCategory = 'file' | 'session' | 'mcp' | 'extension'; + export interface Suggestion { label: string; value: string; @@ -30,6 +34,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/cli/src/ui/hooks/extension-mention-ref.ts b/packages/cli/src/ui/hooks/extension-mention-ref.ts index dd6bd6c08e2..12da6f6b517 100644 --- a/packages/cli/src/ui/hooks/extension-mention-ref.ts +++ b/packages/cli/src/ui/hooks/extension-mention-ref.ts @@ -63,5 +63,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..ad5a0340943 --- /dev/null +++ b/packages/cli/src/ui/hooks/session-completion.test.ts @@ -0,0 +1,81 @@ +/** + * @license + * Copyright 2025 Qwen + * SPDX-License-Identifier: Apache-2.0 + */ + +import { describe, it, expect, vi } 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 } from './session-completion.js'; + +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('returns [] when listSessions throws (I/O failure)', async () => { + mockListSessions.mockRejectedValue(new Error('disk gone')); + const out = await getSessionSuggestions('/proj', ''); + expect(out).toEqual([]); + }); +}); 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..083ee05984b --- /dev/null +++ b/packages/cli/src/ui/hooks/session-completion.ts @@ -0,0 +1,54 @@ +/** + * @license + * Copyright 2025 Qwen + * SPDX-License-Identifier: Apache-2.0 + */ + +import { SessionService } from '@qwen-code/qwen-code-core'; +import type { Suggestion } from '../components/SuggestionsDisplay.js'; +import { buildSessionRef } from './session-mention-ref.js'; +import { t } from '../../i18n/index.js'; + +const MAX_SESSION_SUGGESTIONS = 20; + +/** + * Lists prior sessions for the current project as `@` completion suggestions. + * Scope is enforced by SessionService (current project only). A listing + * failure yields an empty list so the Sessions tab simply shows nothing + * rather than breaking file/MCP/extension completion. + */ +export async function getSessionSuggestions( + cwd: string, + pattern: string, +): Promise<Suggestion[]> { + let items; + try { + const res = await new SessionService(cwd).listSessions({ + size: MAX_SESSION_SUGGESTIONS, + }); + items = res.items; + } catch { + return []; + } + + const needle = pattern.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/useAtCompletion.ts b/packages/cli/src/ui/hooks/useAtCompletion.ts index ce9d3b49118..8fe2c515014 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,24 @@ 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. + 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; + 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 +496,28 @@ export function useAtCompletion(props: UseAtCompletionProps): void { label: p, value: escapePath(p), isDirectory: p.endsWith('/'), + category: 'file' as const, })); + const sessionSuggestions = await sessionPromise; + if (controller.signal.aborted) { + return; + } dispatch({ type: 'SEARCH_SUCCESS', - payload: [...mcpSuggestions, ...fileSuggestions], + payload: [ + ...mcpSuggestions, + ...sessionSuggestions, + ...fileSuggestions, + ], }); } 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; + const scoped = [...mcpSuggestions, ...sessionSuggestions]; + if (scoped.length > 0) { + dispatch({ type: 'SEARCH_SUCCESS', payload: scoped }); } else { dispatch({ type: 'ERROR' }); } From 36004b596b532c48882a2d982d7fd196787cfd54 Mon Sep 17 00:00:00 2001 From: LaZzyMan <zeusdream7@gmail.com> Date: Fri, 17 Jul 2026 16:20:19 +0800 Subject: [PATCH 07/34] feat(cli): tabbed category layout for @ completion dropdown --- .../ui/components/SuggestionsDisplay.test.tsx | 60 ++++++++++++++++ .../src/ui/components/SuggestionsDisplay.tsx | 68 ++++++++++++++++--- 2 files changed, 120 insertions(+), 8 deletions(-) diff --git a/packages/cli/src/ui/components/SuggestionsDisplay.test.tsx b/packages/cli/src/ui/components/SuggestionsDisplay.test.tsx index 87cd2c8c9aa..2f1aff5adb4 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('Sessions'); + }); +}); + 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 742330b89d8..99c48bbf6dd 100644 --- a/packages/cli/src/ui/components/SuggestionsDisplay.tsx +++ b/packages/cli/src/ui/components/SuggestionsDisplay.tsx @@ -64,8 +64,26 @@ 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'>; } +const CATEGORY_LABEL: Record<SuggestionCategory | 'all', string> = { + all: 'All', + file: 'Files', + session: 'Sessions', + mcp: 'MCP', + extension: 'Extensions', +}; + export const MAX_SUGGESTIONS_TO_SHOW = 8; export { MAX_WIDTH }; @@ -99,6 +117,8 @@ export function SuggestionsDisplay({ onHoverIndex, onSelectIndex, mouseEnabled, + activeCategory = 'all', + availableCategories, }: SuggestionsDisplayProps) { const containerRef = useRef<DOMElement | null>(null); const itemRefs = useRef<Array<DOMElement | null>>([]); @@ -111,7 +131,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 } @@ -119,15 +149,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 @@ -136,7 +166,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); @@ -161,6 +191,28 @@ 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} + > + {` ${CATEGORY_LABEL[cat]} `} + </Text> + </Box> + ); + })} + <Box marginLeft={2}> + <Text color={theme.text.secondary}>{t('(←/→ to switch)')}</Text> + </Box> + </Box> + )} {scrollOffset > 0 && <Text color={theme.text.primary}>▲</Text>} {visibleSuggestions.map((suggestion, index) => { @@ -235,10 +287,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> From c3f482ae9fc9fdf8eb4b2ecb044a3a9e2ca02df8 Mon Sep 17 00:00:00 2001 From: LaZzyMan <zeusdream7@gmail.com> Date: Fri, 17 Jul 2026 17:07:29 +0800 Subject: [PATCH 08/34] =?UTF-8?q?feat(cli):=20=E2=86=90/=E2=86=92=20tab=20?= =?UTF-8?q?switching=20for=20@=20completion=20categories?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- packages/cli/src/config/keyBindings.ts | 5 ++ .../cli/src/ui/components/InputPrompt.tsx | 22 ++++++ .../cli/src/ui/hooks/useCommandCompletion.tsx | 17 +++- .../cli/src/ui/hooks/useCompletion.test.ts | 63 +++++++++++++++ packages/cli/src/ui/hooks/useCompletion.ts | 77 ++++++++++++++++++- .../src/ui/hooks/useExportCompletion.test.ts | 3 + packages/cli/src/ui/keyMatchers.test.ts | 4 + 7 files changed, 187 insertions(+), 4 deletions(-) diff --git a/packages/cli/src/config/keyBindings.ts b/packages/cli/src/config/keyBindings.ts index 3b221519523..fda07fb0f6b 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', @@ -181,6 +183,9 @@ export const defaultKeyBindings: KeyBindingConfig = { { key: 'down', shift: false }, { key: 'n', ctrl: true }, ], + // Completion category tab switching (for the tabbed @ completion UI). + [Command.COMPLETION_TAB_LEFT]: [{ key: 'left', shift: false }], + [Command.COMPLETION_TAB_RIGHT]: [{ key: 'right', shift: false }], // Text input // Must also exclude shift to allow shift+enter for newline diff --git a/packages/cli/src/ui/components/InputPrompt.tsx b/packages/cli/src/ui/components/InputPrompt.tsx index 6279e96dc9b..ab55a4b08ed 100644 --- a/packages/cli/src/ui/components/InputPrompt.tsx +++ b/packages/cli/src/ui/components/InputPrompt.tsx @@ -1384,6 +1384,22 @@ export const InputPrompt: React.FC<InputPromptProps> = ({ } if (showCompletionSuggestions) { + // Category tab switching for the tabbed `@` completion UI. Only consume + // ←/→ when there is more than one tab, so plain file/slash completion + // leaves left/right cursor movement in the buffer untouched. + if ((completion.availableCategories?.length ?? 0) > 1) { + 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); @@ -2210,6 +2226,12 @@ export const InputPrompt: React.FC<InputPromptProps> = ({ } expandedIndex={expandedSuggestionIndex} mouseEnabled={mouseInteractionsEnabled} + activeCategory={ + suggestionsFromExport ? undefined : completion.activeCategory + } + availableCategories={ + suggestionsFromExport ? undefined : completion.availableCategories + } onHoverIndex={ suggestionsFromExport ? undefined : handleSuggestionHover } 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..8bc369dde39 100644 --- a/packages/cli/src/ui/hooks/useCompletion.test.ts +++ b/packages/cli/src/ui/hooks/useCompletion.test.ts @@ -233,4 +233,67 @@ 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('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'); + }); + }); }); diff --git a/packages/cli/src/ui/hooks/useCompletion.ts b/packages/cli/src/ui/hooks/useCompletion.ts index 0b1972c76e0..3edffa8ce57 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,53 @@ 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'); + } + }, [availableCategories, activeCategory]); + + 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 +232,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 51fc7a6462c..b3fcebd1f75 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, + [Command.COMPLETION_TAB_RIGHT]: (key: Key) => + key.name === 'right' && !key.shift, [Command.ESCAPE]: (key: Key) => key.name === 'escape', [Command.SUBMIT]: (key: Key) => key.name === 'return' && !key.ctrl && !key.meta && !key.paste, From 05b876c40533a5f142fa143dd2ca6019452a0424 Mon Sep 17 00:00:00 2001 From: LaZzyMan <zeusdream7@gmail.com> Date: Fri, 17 Jul 2026 17:08:53 +0800 Subject: [PATCH 09/34] docs: mark @ session reference design as implemented --- .../superpowers/specs/2026-07-17-at-session-reference-design.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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 index a08ee535153..2313039f6e0 100644 --- a/docs/superpowers/specs/2026-07-17-at-session-reference-design.md +++ b/docs/superpowers/specs/2026-07-17-at-session-reference-design.md @@ -2,7 +2,7 @@ Date: 2026-07-17 Branch: `lazzy/at-session-ref` -Status: Proposed +Status: Implemented ## 1. Goal From dfd60e84e4eebd3f53d04aca9b87bdb8e36f50cd Mon Sep 17 00:00:00 2001 From: LaZzyMan <zeusdream7@gmail.com> Date: Fri, 17 Jul 2026 17:34:46 +0800 Subject: [PATCH 10/34] fix(cli): preserve assistant text on tool-call turns, keep newest turn under budget, guard stale session results --- packages/cli/src/ui/hooks/useAtCompletion.ts | 10 ++- .../session-reference-service.test.ts | 88 +++++++++++++++++++ .../src/services/session-reference-service.ts | 54 ++++++------ 3 files changed, 125 insertions(+), 27 deletions(-) diff --git a/packages/cli/src/ui/hooks/useAtCompletion.ts b/packages/cli/src/ui/hooks/useAtCompletion.ts index 8fe2c515014..34947fb57a9 100644 --- a/packages/cli/src/ui/hooks/useAtCompletion.ts +++ b/packages/cli/src/ui/hooks/useAtCompletion.ts @@ -454,6 +454,11 @@ export function useAtCompletion(props: UseAtCompletionProps): void { // File index not ready yet; still surface non-file matches so they // don't have to wait on the crawler. const sessionSuggestions = await sessionPromise; + // 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) { @@ -499,7 +504,7 @@ export function useAtCompletion(props: UseAtCompletionProps): void { category: 'file' as const, })); const sessionSuggestions = await sessionPromise; - if (controller.signal.aborted) { + if (controller.signal.aborted || cancelled) { return; } dispatch({ @@ -515,6 +520,9 @@ export function useAtCompletion(props: UseAtCompletionProps): void { // A file-search failure shouldn't swallow non-file matches we already // have; show those rather than dropping to an error state. const sessionSuggestions = await sessionPromise; + if (cancelled) { + return; + } const scoped = [...mcpSuggestions, ...sessionSuggestions]; if (scoped.length > 0) { dispatch({ type: 'SEARCH_SUCCESS', payload: scoped }); diff --git a/packages/core/src/services/session-reference-service.test.ts b/packages/core/src/services/session-reference-service.test.ts index d67a9e78286..77dff812f81 100644 --- a/packages/core/src/services/session-reference-service.test.ts +++ b/packages/core/src/services/session-reference-service.test.ts @@ -100,6 +100,94 @@ describe('SessionReferenceService', () => { expect(res.text).toContain('[tool: write_file — error]'); }); + 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 }); + 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', diff --git a/packages/core/src/services/session-reference-service.ts b/packages/core/src/services/session-reference-service.ts index 0edcf77ccd6..5550615a745 100644 --- a/packages/core/src/services/session-reference-service.ts +++ b/packages/core/src/services/session-reference-service.ts @@ -72,8 +72,11 @@ export class SessionReferenceService { const kept = [...lines]; let truncated = false; - while (kept.length > 0 && this.estimate(kept) > budget) { - kept.shift(); // drop oldest first (tail-retention) + // Tail-retention: drop the oldest lines first until under budget, but + // always keep at least the newest line so an over-budget final turn still + // yields content (rather than collapsing to just the omission marker). + while (kept.length > 1 && this.estimate(kept) > budget) { + kept.shift(); truncated = true; } @@ -108,12 +111,10 @@ export class SessionReferenceService { private recordsToLines(records: ChatRecord[]): string[] { const out: string[] = []; for (const rec of records) { - if (rec.toolCallResult || this.hasFunctionPart(rec.message)) { - const name = this.functionName(rec.message) ?? 'tool'; - const status = rec.toolCallResult?.error ? 'error' : 'ok'; - out.push(`[tool: ${name} — ${status}]`); - continue; - } + // 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}`); @@ -121,7 +122,16 @@ export class SessionReferenceService { const text = this.visibleText(rec.message); if (text) out.push(`Assistant: ${text}`); } - // system records ignored + + // 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 status = rec.toolCallResult?.error ? 'error' : 'ok'; + out.push(`[tool: ${name} — ${status}]`); + } + // system records contribute nothing } return out; } @@ -135,22 +145,14 @@ export class SessionReferenceService { .trim(); } - private hasFunctionPart(message?: Content): boolean { - return ( - message?.parts?.some( - (p: Part) => - (p as FunctionCallPart).functionCall || - (p as FunctionCallPart).functionResponse, - ) ?? false - ); - } - - private functionName(message?: Content): string | undefined { - const p = message?.parts?.find( - (x: Part) => - (x as FunctionCallPart).functionCall || - (x as FunctionCallPart).functionResponse, - ) as FunctionCallPart | undefined; - return p?.functionCall?.name ?? p?.functionResponse?.name; + /** 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 FunctionCallPart).functionResponse?.name) + .filter( + (name): name is string => typeof name === 'string' && name.length > 0, + ); } } From 2214de21f4304549ef885379210fe14e3667ff20 Mon Sep 17 00:00:00 2001 From: LaZzyMan <zeusdream7@gmail.com> Date: Fri, 17 Jul 2026 17:38:32 +0800 Subject: [PATCH 11/34] docs(core): clarify SessionReferenceService slimming rules --- packages/core/src/services/session-reference-service.ts | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/packages/core/src/services/session-reference-service.ts b/packages/core/src/services/session-reference-service.ts index 5550615a745..ed4132956a8 100644 --- a/packages/core/src/services/session-reference-service.ts +++ b/packages/core/src/services/session-reference-service.ts @@ -41,11 +41,12 @@ interface ThoughtPart { * mechanical. * * Slimming rules: - * - user / assistant visible text is kept (thoughts dropped); + * - 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); + * (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. + * the oldest turns first but always keeping at least the newest line. */ export class SessionReferenceService { private readonly sessionService: SessionService; From 08bbbc21386ac0cd6db7a6f480f0fd59f2e6b8d4 Mon Sep 17 00:00:00 2001 From: LaZzyMan <zeusdream7@gmail.com> Date: Mon, 20 Jul 2026 10:17:14 +0800 Subject: [PATCH 12/34] perf(cli): remove @ completion input latency from session listing Dispatch file/MCP suggestions immediately and append prior-session suggestions in a second render once the disk listing resolves, instead of blocking the first render on session I/O. Cache the per-cwd session listing for a short TTL so rapid keystrokes don't re-walk the chats dir. --- .../src/ui/hooks/session-completion.test.ts | 57 ++++++++++++++++- .../cli/src/ui/hooks/session-completion.ts | 63 ++++++++++++++++--- .../cli/src/ui/hooks/useAtCompletion.test.ts | 53 ++++++++++++++++ packages/cli/src/ui/hooks/useAtCompletion.ts | 38 ++++++++--- 4 files changed, 191 insertions(+), 20 deletions(-) diff --git a/packages/cli/src/ui/hooks/session-completion.test.ts b/packages/cli/src/ui/hooks/session-completion.test.ts index ad5a0340943..55ae0fb8467 100644 --- a/packages/cli/src/ui/hooks/session-completion.test.ts +++ b/packages/cli/src/ui/hooks/session-completion.test.ts @@ -4,7 +4,7 @@ * SPDX-License-Identifier: Apache-2.0 */ -import { describe, it, expect, vi } from 'vitest'; +import { describe, it, expect, vi, beforeEach } from 'vitest'; const mockListSessions = vi.fn(); @@ -18,7 +18,15 @@ vi.mock('@qwen-code/qwen-code-core', async (orig) => { }; }); -import { getSessionSuggestions } from './session-completion.js'; +import { + getSessionSuggestions, + __resetSessionSuggestionCacheForTest, +} from './session-completion.js'; + +beforeEach(() => { + mockListSessions.mockReset(); + __resetSessionSuggestionCacheForTest(); +}); describe('getSessionSuggestions', () => { it('maps sessions to category:session suggestions with @session: values', async () => { @@ -78,4 +86,49 @@ describe('getSessionSuggestions', () => { 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 index 083ee05984b..3b381609476 100644 --- a/packages/cli/src/ui/hooks/session-completion.ts +++ b/packages/cli/src/ui/hooks/session-completion.ts @@ -5,6 +5,7 @@ */ 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 } from './session-mention-ref.js'; import { t } from '../../i18n/index.js'; @@ -12,24 +13,68 @@ import { t } from '../../i18n/index.js'; const MAX_SESSION_SUGGESTIONS = 20; /** - * Lists prior sessions for the current project as `@` completion suggestions. - * Scope is enforced by SessionService (current project only). A listing - * failure yields an empty list so the Sessions tab simply shows nothing - * rather than breaking file/MCP/extension completion. + * Short TTL for the per-cwd session listing cache. Listing walks the chats dir + * and does bounded reads per session 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. Mirrors the intent of FileSearchFactory's `cacheTtl`. */ -export async function getSessionSuggestions( +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, - pattern: string, -): Promise<Suggestion[]> { - let items; + 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, }); - items = res.items; + 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. + * + * @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); const needle = pattern.trim().toLowerCase(); return items diff --git a/packages/cli/src/ui/hooks/useAtCompletion.test.ts b/packages/cli/src/ui/hooks/useAtCompletion.test.ts index e32ce4229af..8f977b2dff6 100644 --- a/packages/cli/src/ui/hooks/useAtCompletion.test.ts +++ b/packages/cli/src/ui/hooks/useAtCompletion.test.ts @@ -22,6 +22,16 @@ import { import { useState } from 'react'; import type { Suggestion } from '../components/SuggestionsDisplay.js'; +// Session listing is disk I/O; mock it so these tests are deterministic and so +// we can assert the file-first / session-appended dispatch behavior. +const mockGetSessionSuggestions = + vi.fn<(cwd: string, pattern: string) => Promise<Suggestion[]>>(); +vi.mock('./session-completion.js', () => ({ + getSessionSuggestions: (cwd: string, pattern: string) => + mockGetSessionSuggestions(cwd, pattern), + __resetSessionSuggestionCacheForTest: () => {}, +})); + // Test harness to capture the state from the hook's callbacks. function useTestHarnessForAtCompletion( enabled: boolean, @@ -58,6 +68,8 @@ describe('useAtCompletion', () => { getFileFilteringEnableFuzzySearch: () => true, } as unknown as Config; vi.clearAllMocks(); + // Default: no sessions, so existing file-search assertions are unaffected. + mockGetSessionSuggestions.mockResolvedValue([]); }); afterEach(async () => { @@ -159,6 +171,47 @@ describe('useAtCompletion', () => { }); }); + describe('Session suggestions (file-first, appended)', () => { + it('appends session suggestions to the file results once they resolve', async () => { + mockGetSessionSuggestions.mockResolvedValue([ + { + label: 'Fix auth bug', + value: 'session:id-1', + category: 'session', + }, + ]); + const structure: FileSystemStructure = { 'file.txt': '' }; + testRootDir = await createTmpDir(structure); + + const { result } = renderHook(() => + useTestHarnessForAtCompletion(true, '', mockConfig, testRootDir), + ); + + // Eventually both the file and the session suggestion are present. + await waitFor(() => { + const values = result.current.suggestions.map((s) => s.value); + expect(values).toContain('file.txt'); + expect(values).toContain('session:id-1'); + }); + }); + + it('still shows file results when session listing yields nothing', async () => { + mockGetSessionSuggestions.mockResolvedValue([]); + const structure: FileSystemStructure = { 'only.txt': '' }; + testRootDir = await createTmpDir(structure); + + const { result } = renderHook(() => + useTestHarnessForAtCompletion(true, '', mockConfig, testRootDir), + ); + + await waitFor(() => { + expect(result.current.suggestions.map((s) => s.value)).toContain( + 'only.txt', + ); + }); + }); + }); + describe('UI State and Loading Behavior', () => { it('should be in a loading state during initial file system crawl', async () => { testRootDir = await createTmpDir({}); diff --git a/packages/cli/src/ui/hooks/useAtCompletion.ts b/packages/cli/src/ui/hooks/useAtCompletion.ts index 34947fb57a9..fac620b9dcd 100644 --- a/packages/cli/src/ui/hooks/useAtCompletion.ts +++ b/packages/cli/src/ui/hooks/useAtCompletion.ts @@ -503,18 +503,38 @@ export function useAtCompletion(props: UseAtCompletionProps): void { isDirectory: p.endsWith('/'), category: 'file' as const, })); - const sessionSuggestions = await sessionPromise; - if (controller.signal.aborted || cancelled) { - return; - } + + // Dispatch files + MCP IMMEDIATELY so the dropdown appears at file-search + // speed. The session listing (disk I/O) is appended in a SECOND dispatch + // once it resolves — a few ms later — rather than blocking the first + // render. Both dispatches re-check abort/cancel to avoid stale results. dispatch({ type: 'SEARCH_SUCCESS', - payload: [ - ...mcpSuggestions, - ...sessionSuggestions, - ...fileSuggestions, - ], + payload: [...mcpSuggestions, ...fileSuggestions], }); + + sessionPromise + .then((sessionSuggestions) => { + if ( + controller.signal.aborted || + cancelled || + sessionSuggestions.length === 0 + ) { + return; + } + dispatch({ + type: 'SEARCH_SUCCESS', + payload: [ + ...mcpSuggestions, + ...sessionSuggestions, + ...fileSuggestions, + ], + }); + }) + .catch(() => { + // getSessionSuggestions already swallows listing failures; this is a + // belt-and-suspenders guard so a rejected promise never surfaces. + }); } catch (error) { if (!(error instanceof Error && error.name === 'AbortError')) { // A file-search failure shouldn't swallow non-file matches we already From 9083aabcbc4e91ca76c9a2fb7bff8d32397eec14 Mon Sep 17 00:00:00 2001 From: LaZzyMan <zeusdream7@gmail.com> Date: Mon, 20 Jul 2026 13:48:03 +0800 Subject: [PATCH 13/34] Revert "perf(cli): remove @ completion input latency from session listing" This reverts commit 08bbbc21386ac0cd6db7a6f480f0fd59f2e6b8d4. --- .../src/ui/hooks/session-completion.test.ts | 57 +---------------- .../cli/src/ui/hooks/session-completion.ts | 63 +++---------------- .../cli/src/ui/hooks/useAtCompletion.test.ts | 53 ---------------- packages/cli/src/ui/hooks/useAtCompletion.ts | 38 +++-------- 4 files changed, 20 insertions(+), 191 deletions(-) diff --git a/packages/cli/src/ui/hooks/session-completion.test.ts b/packages/cli/src/ui/hooks/session-completion.test.ts index 55ae0fb8467..ad5a0340943 100644 --- a/packages/cli/src/ui/hooks/session-completion.test.ts +++ b/packages/cli/src/ui/hooks/session-completion.test.ts @@ -4,7 +4,7 @@ * SPDX-License-Identifier: Apache-2.0 */ -import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { describe, it, expect, vi } from 'vitest'; const mockListSessions = vi.fn(); @@ -18,15 +18,7 @@ vi.mock('@qwen-code/qwen-code-core', async (orig) => { }; }); -import { - getSessionSuggestions, - __resetSessionSuggestionCacheForTest, -} from './session-completion.js'; - -beforeEach(() => { - mockListSessions.mockReset(); - __resetSessionSuggestionCacheForTest(); -}); +import { getSessionSuggestions } from './session-completion.js'; describe('getSessionSuggestions', () => { it('maps sessions to category:session suggestions with @session: values', async () => { @@ -86,49 +78,4 @@ describe('getSessionSuggestions', () => { 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 index 3b381609476..083ee05984b 100644 --- a/packages/cli/src/ui/hooks/session-completion.ts +++ b/packages/cli/src/ui/hooks/session-completion.ts @@ -5,7 +5,6 @@ */ 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 } from './session-mention-ref.js'; import { t } from '../../i18n/index.js'; @@ -13,68 +12,24 @@ 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 - * and does bounded reads per session 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. Mirrors the intent of FileSearchFactory's `cacheTtl`. + * Lists prior sessions for the current project as `@` completion suggestions. + * Scope is enforced by SessionService (current project only). A listing + * failure yields an empty list so the Sessions tab simply shows nothing + * rather than breaking file/MCP/extension completion. */ -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( +export async function getSessionSuggestions( cwd: string, - nowMs: number, -): Promise<SessionListItem[]> { - const cached = listingCache.get(cwd); - if (cached && cached.expiresAt > nowMs) { - return cached.items; - } + pattern: string, +): Promise<Suggestion[]> { + let 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; + items = 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. - * - * @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); const needle = pattern.trim().toLowerCase(); return items diff --git a/packages/cli/src/ui/hooks/useAtCompletion.test.ts b/packages/cli/src/ui/hooks/useAtCompletion.test.ts index 8f977b2dff6..e32ce4229af 100644 --- a/packages/cli/src/ui/hooks/useAtCompletion.test.ts +++ b/packages/cli/src/ui/hooks/useAtCompletion.test.ts @@ -22,16 +22,6 @@ import { import { useState } from 'react'; import type { Suggestion } from '../components/SuggestionsDisplay.js'; -// Session listing is disk I/O; mock it so these tests are deterministic and so -// we can assert the file-first / session-appended dispatch behavior. -const mockGetSessionSuggestions = - vi.fn<(cwd: string, pattern: string) => Promise<Suggestion[]>>(); -vi.mock('./session-completion.js', () => ({ - getSessionSuggestions: (cwd: string, pattern: string) => - mockGetSessionSuggestions(cwd, pattern), - __resetSessionSuggestionCacheForTest: () => {}, -})); - // Test harness to capture the state from the hook's callbacks. function useTestHarnessForAtCompletion( enabled: boolean, @@ -68,8 +58,6 @@ describe('useAtCompletion', () => { getFileFilteringEnableFuzzySearch: () => true, } as unknown as Config; vi.clearAllMocks(); - // Default: no sessions, so existing file-search assertions are unaffected. - mockGetSessionSuggestions.mockResolvedValue([]); }); afterEach(async () => { @@ -171,47 +159,6 @@ describe('useAtCompletion', () => { }); }); - describe('Session suggestions (file-first, appended)', () => { - it('appends session suggestions to the file results once they resolve', async () => { - mockGetSessionSuggestions.mockResolvedValue([ - { - label: 'Fix auth bug', - value: 'session:id-1', - category: 'session', - }, - ]); - const structure: FileSystemStructure = { 'file.txt': '' }; - testRootDir = await createTmpDir(structure); - - const { result } = renderHook(() => - useTestHarnessForAtCompletion(true, '', mockConfig, testRootDir), - ); - - // Eventually both the file and the session suggestion are present. - await waitFor(() => { - const values = result.current.suggestions.map((s) => s.value); - expect(values).toContain('file.txt'); - expect(values).toContain('session:id-1'); - }); - }); - - it('still shows file results when session listing yields nothing', async () => { - mockGetSessionSuggestions.mockResolvedValue([]); - const structure: FileSystemStructure = { 'only.txt': '' }; - testRootDir = await createTmpDir(structure); - - const { result } = renderHook(() => - useTestHarnessForAtCompletion(true, '', mockConfig, testRootDir), - ); - - await waitFor(() => { - expect(result.current.suggestions.map((s) => s.value)).toContain( - 'only.txt', - ); - }); - }); - }); - describe('UI State and Loading Behavior', () => { it('should be in a loading state during initial file system crawl', async () => { testRootDir = await createTmpDir({}); diff --git a/packages/cli/src/ui/hooks/useAtCompletion.ts b/packages/cli/src/ui/hooks/useAtCompletion.ts index fac620b9dcd..34947fb57a9 100644 --- a/packages/cli/src/ui/hooks/useAtCompletion.ts +++ b/packages/cli/src/ui/hooks/useAtCompletion.ts @@ -503,38 +503,18 @@ export function useAtCompletion(props: UseAtCompletionProps): void { isDirectory: p.endsWith('/'), category: 'file' as const, })); - - // Dispatch files + MCP IMMEDIATELY so the dropdown appears at file-search - // speed. The session listing (disk I/O) is appended in a SECOND dispatch - // once it resolves — a few ms later — rather than blocking the first - // render. Both dispatches re-check abort/cancel to avoid stale results. + const sessionSuggestions = await sessionPromise; + if (controller.signal.aborted || cancelled) { + return; + } dispatch({ type: 'SEARCH_SUCCESS', - payload: [...mcpSuggestions, ...fileSuggestions], + payload: [ + ...mcpSuggestions, + ...sessionSuggestions, + ...fileSuggestions, + ], }); - - sessionPromise - .then((sessionSuggestions) => { - if ( - controller.signal.aborted || - cancelled || - sessionSuggestions.length === 0 - ) { - return; - } - dispatch({ - type: 'SEARCH_SUCCESS', - payload: [ - ...mcpSuggestions, - ...sessionSuggestions, - ...fileSuggestions, - ], - }); - }) - .catch(() => { - // getSessionSuggestions already swallows listing failures; this is a - // belt-and-suspenders guard so a rejected promise never surfaces. - }); } catch (error) { if (!(error instanceof Error && error.name === 'AbortError')) { // A file-search failure shouldn't swallow non-file matches we already From 0b6c474e84aa548ee3600b188bbc458b678afcd7 Mon Sep 17 00:00:00 2001 From: qwen-code-ci-bot <qwen-code-ci-bot@users.noreply.github.com> Date: Tue, 21 Jul 2026 05:13:10 +0000 Subject: [PATCH 14/34] fix(cli): harden session ref resolution and i18n tab labels (#7302) --- .../src/ui/components/SuggestionsDisplay.tsx | 25 ++++++++----- .../hooks/atCommandProcessor.session.test.ts | 22 ++++++++++++ .../cli/src/ui/hooks/atCommandProcessor.ts | 36 +++++++++++++++---- 3 files changed, 69 insertions(+), 14 deletions(-) diff --git a/packages/cli/src/ui/components/SuggestionsDisplay.tsx b/packages/cli/src/ui/components/SuggestionsDisplay.tsx index b08d5e9a7c2..cd73315b89c 100644 --- a/packages/cli/src/ui/components/SuggestionsDisplay.tsx +++ b/packages/cli/src/ui/components/SuggestionsDisplay.tsx @@ -47,13 +47,22 @@ interface SuggestionsDisplayProps { availableCategories?: Array<SuggestionCategory | 'all'>; } -const CATEGORY_LABEL: Record<SuggestionCategory | 'all', string> = { - all: 'All', - file: 'Files', - session: 'Sessions', - mcp: 'MCP', - extension: 'Extensions', -}; +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 'MCP'; + case 'extension': + return t('Extensions'); + default: + return cat; + } +} export { MAX_WIDTH }; @@ -173,7 +182,7 @@ export function SuggestionsDisplay({ } backgroundColor={active ? theme.text.accent : undefined} > - {` ${CATEGORY_LABEL[cat]} `} + {` ${categoryLabel(cat)} `} </Text> </Box> ); diff --git a/packages/cli/src/ui/hooks/atCommandProcessor.session.test.ts b/packages/cli/src/ui/hooks/atCommandProcessor.session.test.ts index 6fd9752b3ac..1c59715af81 100644 --- a/packages/cli/src/ui/hooks/atCommandProcessor.session.test.ts +++ b/packages/cli/src/ui/hooks/atCommandProcessor.session.test.ts @@ -173,4 +173,26 @@ describe('handleAtCommand @session:', () => { '@session: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'); + expect( + result.toolDisplays?.some( + (d) => d.name === 'Referenced Session' && d.status !== undefined, + ), + ).toBe(true); + }); }); diff --git a/packages/cli/src/ui/hooks/atCommandProcessor.ts b/packages/cli/src/ui/hooks/atCommandProcessor.ts index 0e00c552159..a5476b90146 100644 --- a/packages/cli/src/ui/hooks/atCommandProcessor.ts +++ b/packages/cli/src/ui/hooks/atCommandProcessor.ts @@ -641,9 +641,14 @@ export async function resolveAtCommandQuery({ let sessionId = ref.id; if (!sessionId && ref.title) { - const matches = await new SessionService( - config.getProjectRoot(), - ).findSessionsByTitle(ref.title); + let matches: Array<{ sessionId: string }> = []; + try { + matches = await new SessionService( + config.getProjectRoot(), + ).findSessionsByTitle(ref.title); + } catch { + // title scan failure → fall through to the not-found path + } if (matches.length === 1) { sessionId = matches[0].sessionId; } else { @@ -669,9 +674,28 @@ export async function resolveAtCommandQuery({ } } + 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; + } + const resolved = await new SessionReferenceService( config.getProjectRoot(), - ).resolve(sessionId!, ref.title ? { title: ref.title } : {}); + ).resolve(sessionId, ref.title ? { title: ref.title } : {}); if ('notFound' in resolved) { const reason = `Session "${sessionId}" not found in this project.`; @@ -679,7 +703,7 @@ export async function resolveAtCommandQuery({ scopedMentionEntries.push({ originalAtPath, part: { text: '' }, - label: buildSessionRef(sessionId!), + label: buildSessionRef(sessionId), display: { callId, name: 'Referenced Session', @@ -695,7 +719,7 @@ export async function resolveAtCommandQuery({ scopedMentionEntries.push({ originalAtPath, part: { text: resolved.text }, - label: buildSessionRef(sessionId!), + label: buildSessionRef(sessionId), display: { callId, name: 'Referenced Session', From d7c341f7e55d525fb159f802b3c015a3160b00aa Mon Sep 17 00:00:00 2001 From: Qwen Code Bot <qwen-code-bot@users.noreply.github.com> Date: Tue, 21 Jul 2026 07:15:27 +0000 Subject: [PATCH 15/34] fix(cli): guard session ref I/O errors to never abort the turn (#7302) --- .../hooks/atCommandProcessor.session.test.ts | 33 +++++++++++--- .../cli/src/ui/hooks/atCommandProcessor.ts | 43 +++++++++++++++++-- 2 files changed, 67 insertions(+), 9 deletions(-) diff --git a/packages/cli/src/ui/hooks/atCommandProcessor.session.test.ts b/packages/cli/src/ui/hooks/atCommandProcessor.session.test.ts index 1c59715af81..593c1bc91a7 100644 --- a/packages/cli/src/ui/hooks/atCommandProcessor.session.test.ts +++ b/packages/cli/src/ui/hooks/atCommandProcessor.session.test.ts @@ -189,10 +189,33 @@ describe('handleAtCommand @session:', () => { expect(result.shouldProceed).toBe(true); const joined = JSON.stringify(result.processedQuery); expect(joined).toContain('@session:Some Title'); - expect( - result.toolDisplays?.some( - (d) => d.name === 'Referenced Session' && d.status !== undefined, - ), - ).toBe(true); + const card = result.toolDisplays?.find( + (d) => d.name === 'Referenced Session', + ); + expect(card).toBeDefined(); + expect(card!.resultDisplay).toContain('I/O error'); + 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('I/O error'); }); }); diff --git a/packages/cli/src/ui/hooks/atCommandProcessor.ts b/packages/cli/src/ui/hooks/atCommandProcessor.ts index a5476b90146..235070ef2db 100644 --- a/packages/cli/src/ui/hooks/atCommandProcessor.ts +++ b/packages/cli/src/ui/hooks/atCommandProcessor.ts @@ -647,7 +647,22 @@ export async function resolveAtCommandQuery({ config.getProjectRoot(), ).findSessionsByTitle(ref.title); } catch { - // title scan failure → fall through to the not-found path + const reason = `Could not look up sessions matching "@${originalAtPath.substring(1)}" (I/O 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; @@ -693,9 +708,29 @@ export async function resolveAtCommandQuery({ continue; } - const resolved = await new SessionReferenceService( - config.getProjectRoot(), - ).resolve(sessionId, ref.title ? { title: ref.title } : {}); + let resolved; + try { + resolved = await new SessionReferenceService( + config.getProjectRoot(), + ).resolve(sessionId, ref.title ? { title: ref.title } : {}); + } catch { + const reason = `Failed to load session "${sessionId}" (I/O 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.`; From ffd66452bf799637312bb947206ece8d42630701 Mon Sep 17 00:00:00 2001 From: Qwen Code Bot <qwen-code-bot@users.noreply.github.com> Date: Tue, 21 Jul 2026 08:54:50 +0000 Subject: [PATCH 16/34] fix(cli): strip session: prefix in completion filter and align tab guard (#7302) --- .../cli/src/ui/components/InputPrompt.tsx | 2 +- .../src/ui/hooks/session-completion.test.ts | 23 ++++++++ .../cli/src/ui/hooks/session-completion.ts | 5 +- .../src/ui/hooks/session-mention-ref.test.ts | 6 ++ .../cli/src/ui/hooks/useAtCompletion.test.ts | 56 +++++++++++++++++++ 5 files changed, 90 insertions(+), 2 deletions(-) diff --git a/packages/cli/src/ui/components/InputPrompt.tsx b/packages/cli/src/ui/components/InputPrompt.tsx index 15740a31035..81ba39a0a73 100644 --- a/packages/cli/src/ui/components/InputPrompt.tsx +++ b/packages/cli/src/ui/components/InputPrompt.tsx @@ -1400,7 +1400,7 @@ export const InputPrompt: React.FC<InputPromptProps> = ({ // Category tab switching for the tabbed `@` completion UI. Only consume // ←/→ when there is more than one tab, so plain file/slash completion // leaves left/right cursor movement in the buffer untouched. - if ((completion.availableCategories?.length ?? 0) > 1) { + if ((completion.availableCategories?.length ?? 0) > 2) { if (keyMatchers[Command.COMPLETION_TAB_RIGHT](key)) { completion.switchCategory(1); setExpandedSuggestionIndex(-1); diff --git a/packages/cli/src/ui/hooks/session-completion.test.ts b/packages/cli/src/ui/hooks/session-completion.test.ts index ad5a0340943..a42eaf4381b 100644 --- a/packages/cli/src/ui/hooks/session-completion.test.ts +++ b/packages/cli/src/ui/hooks/session-completion.test.ts @@ -73,6 +73,29 @@ describe('getSessionSuggestions', () => { 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('returns [] when listSessions throws (I/O failure)', async () => { mockListSessions.mockRejectedValue(new Error('disk gone')); const out = await getSessionSuggestions('/proj', ''); diff --git a/packages/cli/src/ui/hooks/session-completion.ts b/packages/cli/src/ui/hooks/session-completion.ts index 083ee05984b..709c2fdec74 100644 --- a/packages/cli/src/ui/hooks/session-completion.ts +++ b/packages/cli/src/ui/hooks/session-completion.ts @@ -31,7 +31,10 @@ export async function getSessionSuggestions( return []; } - const needle = pattern.trim().toLowerCase(); + const stripped = pattern.startsWith('session:') + ? pattern.slice('session:'.length) + : pattern; + const needle = stripped.trim().toLowerCase(); return items .map((s) => { const label = s.customTitle?.trim() || s.prompt || s.sessionId; diff --git a/packages/cli/src/ui/hooks/session-mention-ref.test.ts b/packages/cli/src/ui/hooks/session-mention-ref.test.ts index 9a55a1476df..fa997c49b5c 100644 --- a/packages/cli/src/ui/hooks/session-mention-ref.test.ts +++ b/packages/cli/src/ui/hooks/session-mention-ref.test.ts @@ -1,3 +1,9 @@ +/** + * @license + * Copyright 2025 Qwen + * SPDX-License-Identifier: Apache-2.0 + */ + import { describe, it, expect } from 'vitest'; import { parseSessionRef, diff --git a/packages/cli/src/ui/hooks/useAtCompletion.test.ts b/packages/cli/src/ui/hooks/useAtCompletion.test.ts index e32ce4229af..42e18587005 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 () => { @@ -1153,4 +1160,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); + }); + }); }); From 40cc4ccf6e3c0a40950f82e297d28ef0e0d18a31 Mon Sep 17 00:00:00 2001 From: qwen-code-ci-bot <qwen-code-ci-bot@users.noreply.github.com> Date: Tue, 21 Jul 2026 11:39:36 +0000 Subject: [PATCH 17/34] fix(cli): address review feedback on session refs and tab tests (#7302) --- .../ui/components/SuggestionsDisplay.test.tsx | 2 +- .../hooks/atCommandProcessor.session.test.ts | 22 +++++++++++++++++++ .../cli/src/ui/hooks/atCommandProcessor.ts | 9 +++++++- packages/cli/src/ui/keyMatchers.test.ts | 10 +++++++++ 4 files changed, 41 insertions(+), 2 deletions(-) diff --git a/packages/cli/src/ui/components/SuggestionsDisplay.test.tsx b/packages/cli/src/ui/components/SuggestionsDisplay.test.tsx index 2f1aff5adb4..c6959963568 100644 --- a/packages/cli/src/ui/components/SuggestionsDisplay.test.tsx +++ b/packages/cli/src/ui/components/SuggestionsDisplay.test.tsx @@ -227,7 +227,7 @@ describe('SuggestionsDisplay tabs', () => { availableCategories={['all', 'file']} />, ); - expect(lastFrame()).not.toContain('Sessions'); + expect(lastFrame()).not.toContain('Files'); }); }); diff --git a/packages/cli/src/ui/hooks/atCommandProcessor.session.test.ts b/packages/cli/src/ui/hooks/atCommandProcessor.session.test.ts index 593c1bc91a7..393d7f0d006 100644 --- a/packages/cli/src/ui/hooks/atCommandProcessor.session.test.ts +++ b/packages/cli/src/ui/hooks/atCommandProcessor.session.test.ts @@ -218,4 +218,26 @@ describe('handleAtCommand @session:', () => { expect(card).toBeDefined(); expect(card!.resultDisplay).toContain('I/O error'); }); + + 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 235070ef2db..9b0cd9f7a00 100644 --- a/packages/cli/src/ui/hooks/atCommandProcessor.ts +++ b/packages/cli/src/ui/hooks/atCommandProcessor.ts @@ -331,7 +331,14 @@ export async function resolveAtCommandQuery({ // token verbatim in the prompt text. const sessionRef = parseSessionRef(pathName); if (sessionRef) { - sessionMentions.push({ originalAtPath, ref: 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; } diff --git a/packages/cli/src/ui/keyMatchers.test.ts b/packages/cli/src/ui/keyMatchers.test.ts index 58a83c56e0d..50e46fb33b3 100644 --- a/packages/cli/src/ui/keyMatchers.test.ts +++ b/packages/cli/src/ui/keyMatchers.test.ts @@ -236,6 +236,16 @@ describe('keyMatchers', () => { createKey('down', { shift: true }), ], }, + { + command: Command.COMPLETION_TAB_LEFT, + positive: [createKey('left')], + negative: [createKey('left', { shift: true }), createKey('right')], + }, + { + command: Command.COMPLETION_TAB_RIGHT, + positive: [createKey('right')], + negative: [createKey('right', { shift: true }), createKey('left')], + }, // Text input { From 645c8a335e55aa5c4a200f063011f3b5a762c785 Mon Sep 17 00:00:00 2001 From: qwen-code-ci-bot <qwen-code-ci-bot@users.noreply.github.com> Date: Tue, 21 Jul 2026 13:59:55 +0000 Subject: [PATCH 18/34] fix(cli): use SESSION_MENTION_PREFIX constant in completion filter (#7302) --- packages/cli/src/ui/hooks/session-completion.ts | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/packages/cli/src/ui/hooks/session-completion.ts b/packages/cli/src/ui/hooks/session-completion.ts index 709c2fdec74..6acd1494ccf 100644 --- a/packages/cli/src/ui/hooks/session-completion.ts +++ b/packages/cli/src/ui/hooks/session-completion.ts @@ -6,7 +6,10 @@ import { SessionService } from '@qwen-code/qwen-code-core'; import type { Suggestion } from '../components/SuggestionsDisplay.js'; -import { buildSessionRef } from './session-mention-ref.js'; +import { + buildSessionRef, + SESSION_MENTION_PREFIX, +} from './session-mention-ref.js'; import { t } from '../../i18n/index.js'; const MAX_SESSION_SUGGESTIONS = 20; @@ -31,8 +34,8 @@ export async function getSessionSuggestions( return []; } - const stripped = pattern.startsWith('session:') - ? pattern.slice('session:'.length) + const stripped = pattern.startsWith(SESSION_MENTION_PREFIX) + ? pattern.slice(SESSION_MENTION_PREFIX.length) : pattern; const needle = stripped.trim().toLowerCase(); return items From a50b7609cea3deb34ea64b0a2423339d5370d1ae Mon Sep 17 00:00:00 2001 From: Qwen Code Bot <qwen-code-bot@users.noreply.github.com> Date: Tue, 21 Jul 2026 16:23:52 +0000 Subject: [PATCH 19/34] fix(cli): address review feedback on session refs and tab tests (#7302) --- .../plans/2026-07-17-at-session-reference.md | 182 +++++++++++++----- .../src/ui/components/InputPrompt.test.tsx | 59 ++++++ .../hooks/atCommandProcessor.session.test.ts | 24 +++ .../cli/src/ui/hooks/atCommandProcessor.ts | 8 + .../src/services/session-reference-service.ts | 11 +- 5 files changed, 235 insertions(+), 49 deletions(-) diff --git a/docs/superpowers/plans/2026-07-17-at-session-reference.md b/docs/superpowers/plans/2026-07-17-at-session-reference.md index a6b08ec26be..a2297fd2c97 100644 --- a/docs/superpowers/plans/2026-07-17-at-session-reference.md +++ b/docs/superpowers/plans/2026-07-17-at-session-reference.md @@ -24,10 +24,12 @@ ### Task 1: `sessionMentionRef` — parse/build/validate `@session:` refs **Files:** + - Create: `packages/cli/src/ui/hooks/sessionMentionRef.ts` - Test: `packages/cli/src/ui/hooks/sessionMentionRef.test.ts` **Interfaces:** + - Consumes: nothing (pure string module). - Produces: - `const SESSION_MENTION_PREFIX = 'session:'` @@ -137,11 +139,13 @@ git commit -m "feat(cli): add @session: mention ref parser" ### Task 2: `SessionReferenceService` — load + slim + budget-trim **Files:** + - Create: `packages/core/src/services/sessionReferenceService.ts` - Test: `packages/core/src/services/sessionReferenceService.test.ts` - Modify (export barrel): `packages/core/src/index.ts` (add `export * from './services/sessionReferenceService.js';` alongside existing service exports) **Interfaces:** + - Consumes: `SessionService.loadSession(id): Promise<ResumedSessionData | undefined>` (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` @@ -149,12 +153,14 @@ git commit -m "feat(cli): add @session: mention ref parser" - `class SessionReferenceService { constructor(cwd: string); resolve(ref: { id?: string; title?: string }, opts?: { budgetTokens?: number }): Promise<SlimmedSessionReference | { notFound: true } | { ambiguous: true; count: number }> }` 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: <displayName || name> — <status || 'ok'>]`. 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`. Revised Produces (authoritative): + ```ts resolve(sessionId: string, opts?: { budgetTokens?: number }): Promise<SlimmedSessionReference | { notFound: true }> @@ -185,8 +191,9 @@ function fakeResumed(messages: unknown[]): ResumedSessionData { function makeSvc(resumed: ResumedSessionData | undefined) { const svc = new SessionReferenceService('/proj'); // Inject a stub SessionService.loadSession - (svc as unknown as { loadSession: () => Promise<unknown> }).loadSession = - vi.fn().mockResolvedValue(resumed); + (svc as unknown as { loadSession: () => Promise<unknown> }).loadSession = vi + .fn() + .mockResolvedValue(resumed); return svc; } @@ -224,7 +231,11 @@ describe('SessionReferenceService', () => { toolCallResult: { displayName: 'Read File', status: 'success' }, message: { role: 'user', - parts: [{ functionResponse: { name: 'read', response: { huge: 'BODY' } } }], + parts: [ + { + functionResponse: { name: 'read', response: { huge: 'BODY' } }, + }, + ], }, }, ]), @@ -238,7 +249,10 @@ describe('SessionReferenceService', () => { 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) }] }, + message: { + role: 'user', + parts: [{ text: `turn ${i} ` + 'x'.repeat(400) }], + }, })); const svc = makeSvc(fakeResumed(many)); const res = await svc.resolve('s1', { budgetTokens: 200 }); @@ -306,7 +320,8 @@ export class SessionReferenceService { kept.shift(); // drop oldest first (tail-retention) truncated = true; } - const body = (truncated ? '[earlier turns omitted]\n' : '') + kept.join('\n'); + const body = + (truncated ? '[earlier turns omitted]\n' : '') + kept.join('\n'); const title = resumed.conversation.messages.length > 0 ? sessionId // caller supplies a friendlier title in Task 3; id fallback here @@ -358,6 +373,11 @@ export class SessionReferenceService { } 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 ''; @@ -400,9 +420,11 @@ 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/sessionReferenceService.js'; ``` + Run: `npx tsc --noEmit -p packages/core/tsconfig.json` Expected: no errors in the new file. @@ -418,14 +440,17 @@ git commit -m "feat(core): add SessionReferenceService for slimmed session injec ### 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<SessionListItem[]>`. - 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`. @@ -450,7 +475,12 @@ vi.mock('@qwen-code/qwen-code-core', async (orig) => { SessionReferenceService: class { resolve = vi.fn().mockResolvedValue({ text: '--- Referenced session "s1" (slimmed, read-only) ---\nUser: hi', - meta: { sessionId: 's1', title: 's1', messageCount: 1, approxTokens: 5 }, + meta: { + sessionId: 's1', + title: 's1', + messageCount: 1, + approxTokens: 5, + }, truncated: false, }); }, @@ -479,11 +509,14 @@ Expected: FAIL — assertion fails (session text not injected) because the branc - [ ] **Step 3: Implement the routing branch** In `atCommandProcessor.ts`, add imports at the top: + ```ts import { parseSessionRef } from './sessionMentionRef.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) { @@ -509,9 +542,9 @@ if (sessionRef) { continue; // token already retained as literal text } } - const ref = await new SessionReferenceService( - config.getWorkingDir(), - ).resolve(sessionId!); + const ref = await new SessionReferenceService(config.getWorkingDir()).resolve( + sessionId!, + ); if ('notFound' in ref) { addItem( { type: MessageType.INFO, text: `Session "${sessionId}" not found.` }, @@ -527,6 +560,7 @@ if (sessionRef) { 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** @@ -553,12 +587,14 @@ git commit -m "feat(cli): inject slimmed prior-session context on @session: ment ### 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/sessionCompletion.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/sessionCompletion.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'` @@ -568,16 +604,20 @@ git commit -m "feat(cli): inject slimmed prior-session context on @session: ment - [ ] **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/sessionCompletion.test.ts import { describe, it, expect, vi } from 'vitest'; @@ -589,8 +629,18 @@ vi.mock('@qwen-code/qwen-code-core', async (orig) => { 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 }, + { + sessionId: 'id-1', + customTitle: 'Fix auth bug', + prompt: 'fix auth', + mtime: 2, + }, + { + sessionId: 'id-2', + customTitle: undefined, + prompt: 'add tests', + mtime: 1, + }, ], hasMore: false, }); @@ -664,7 +714,9 @@ export async function getSessionSuggestions( .filter((sug) => needle.length === 0 ? true - : `${sug.label} ${sug.description ?? ''}`.toLowerCase().includes(needle), + : `${sug.label} ${sug.description ?? ''}` + .toLowerCase() + .includes(needle), ); } ``` @@ -678,6 +730,7 @@ Expected: PASS (2 tests). - Add import: `import { getSessionSuggestions } from './sessionCompletion.js';` - Tag file results with `category: 'file'` at the `fileSuggestions` map (~line 486): + ```ts const fileSuggestions = results.map((p) => ({ label: p, @@ -686,8 +739,10 @@ const fileSuggestions = results.map((p) => ({ 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(), @@ -699,6 +754,7 @@ dispatch({ 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** @@ -719,10 +775,12 @@ 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<SuggestionCategory | 'all'>`. 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). @@ -803,11 +861,14 @@ Expected: FAIL — tab labels absent / props unknown. - [ ] **Step 3: Implement tab bar + filter** - Extend `SuggestionsDisplayProps` with: + ```ts activeCategory?: SuggestionCategory | 'all'; availableCategories?: Array<SuggestionCategory | 'all'>; ``` + - Add a label map: + ```ts const CATEGORY_LABEL: Record<SuggestionCategory | 'all', string> = { all: 'All', @@ -817,36 +878,43 @@ const CATEGORY_LABEL: Record<SuggestionCategory | 'all', string> = { 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. + - Render a tab bar (mirror `StatsTabs` in `StatsDialog.tsx`) above the list, only when `(availableCategories?.length ?? 0) > 2`: + ```tsx -{(availableCategories?.length ?? 0) > 2 && ( - <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} - > - {` ${CATEGORY_LABEL[cat]} `} - </Text> - </Box> - ); - })} - <Box marginLeft={2}> - <Text color={theme.text.secondary}>(←/→ to switch)</Text> +{ + (availableCategories?.length ?? 0) > 2 && ( + <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} + > + {` ${CATEGORY_LABEL[cat]} `} + </Text> + </Box> + ); + })} + <Box marginLeft={2}> + <Text color={theme.text.secondary}>(←/→ to switch)</Text> + </Box> </Box> - </Box> -)} + ); +} ``` - [ ] **Step 4: Run test to verify it passes** @@ -866,12 +934,14 @@ 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<SuggestionCategory | 'all'>`, `switchCategory(direction: 1 | -1): void`. @@ -892,13 +962,18 @@ it('derives availableCategories and cycles with switchCategory', () => { { label: 'S', value: 'session:1', category: 'session' }, ]); }); - expect(result.current.availableCategories).toEqual(['all', 'file', '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** @@ -909,38 +984,52 @@ Expected: FAIL — `availableCategories`/`switchCategory` undefined. - [ ] **Step 3: Implement state in `useCompletion.ts`** Add: + ```ts -const CATEGORY_ORDER: SuggestionCategory[] = ['file', 'session', 'mcp', 'extension']; +const CATEGORY_ORDER: SuggestionCategory[] = [ + 'file', + 'session', + 'mcp', + 'extension', +]; const availableCategories = useMemo<Array<SuggestionCategory | 'all'>>(() => { 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] : ordered.length === 1 ? ['all', ...ordered] : ['all']; + return ordered.length > 1 ? ['all', ...ordered] : ['all']; }, [suggestions]); -const [activeCategory, setActiveCategory] = useState<SuggestionCategory | 'all'>('all'); +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]); +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], +); ``` + 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); @@ -951,11 +1040,14 @@ if (keyMatchers[Command.COMPLETION_TAB_LEFT](key)) { 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** diff --git a/packages/cli/src/ui/components/InputPrompt.test.tsx b/packages/cli/src/ui/components/InputPrompt.test.tsx index f0232864c80..5d556968b77 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,62 @@ 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', 'file'], + 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 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', 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'); // right arrow + await wait(); + + expect(switchCategory).toHaveBeenCalledWith(1); + unmount(); + }); + it('should reset history navigation after submitting on Enter', async () => { mockedUseCommandCompletion.mockReturnValue({ ...mockCommandCompletion, diff --git a/packages/cli/src/ui/hooks/atCommandProcessor.session.test.ts b/packages/cli/src/ui/hooks/atCommandProcessor.session.test.ts index 393d7f0d006..85f14fef0c0 100644 --- a/packages/cli/src/ui/hooks/atCommandProcessor.session.test.ts +++ b/packages/cli/src/ui/hooks/atCommandProcessor.session.test.ts @@ -219,6 +219,30 @@ describe('handleAtCommand @session:', () => { expect(card!.resultDisplay).toContain('I/O error'); }); + 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); + }); + it('deduplicates identical session mentions', async () => { mockResolve.mockResolvedValue({ text: '--- Referenced session "s1" (slimmed, read-only) ---\nUser: hi', diff --git a/packages/cli/src/ui/hooks/atCommandProcessor.ts b/packages/cli/src/ui/hooks/atCommandProcessor.ts index 9b0cd9f7a00..20ea495018c 100644 --- a/packages/cli/src/ui/hooks/atCommandProcessor.ts +++ b/packages/cli/src/ui/hooks/atCommandProcessor.ts @@ -642,6 +642,7 @@ export async function resolveAtCommandQuery({ // 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}`; @@ -715,6 +716,13 @@ export async function resolveAtCommandQuery({ 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)) { + continue; + } + resolvedSessionIds.add(sessionId); + let resolved; try { resolved = await new SessionReferenceService( diff --git a/packages/core/src/services/session-reference-service.ts b/packages/core/src/services/session-reference-service.ts index ed4132956a8..af4e2b4f325 100644 --- a/packages/core/src/services/session-reference-service.ts +++ b/packages/core/src/services/session-reference-service.ts @@ -71,18 +71,21 @@ export class SessionReferenceService { const lines = this.recordsToLines(records); const budget = opts.budgetTokens ?? SESSION_REF_TOKEN_BUDGET; + const title = opts.title ?? sessionId; + const header = `--- Referenced session "${title}" (slimmed, read-only) ---`; + // Budget for the header and potential truncation marker so the final + // injected text stays within the caller's budget. + const overhead = this.estimate([header, '[earlier turns omitted]']); + const kept = [...lines]; let truncated = false; // Tail-retention: drop the oldest lines first until under budget, but // always keep at least the newest line so an over-budget final turn still // yields content (rather than collapsing to just the omission marker). - while (kept.length > 1 && this.estimate(kept) > budget) { + while (kept.length > 1 && this.estimate(kept) + overhead > budget) { kept.shift(); truncated = true; } - - const title = opts.title ?? sessionId; - const header = `--- Referenced session "${title}" (slimmed, read-only) ---`; const body = (truncated ? '[earlier turns omitted]\n' : '') + kept.join('\n'); const text = From 34e57e8ac477830bd5ae9317bbf7d226a4f92302 Mon Sep 17 00:00:00 2001 From: Qwen Code Bot <qwen-code-bot@users.noreply.github.com> Date: Tue, 21 Jul 2026 18:58:07 +0000 Subject: [PATCH 20/34] fix(cli): address review feedback on session refs and tab tests (#7302) - Constrain ctrl/command on completion tab-switch bindings so ctrl+left/right (word-jump) is not consumed for tab switching - Derive a friendly title from the first user message in SessionReferenceService.resolve() when no explicit title is given, so UUID-based refs show meaningful context headers - Fix dead conditional in plan doc code snippet --- .../plans/2026-07-17-at-session-reference.md | 5 +- packages/cli/src/config/keyBindings.ts | 8 +- packages/cli/src/ui/keyMatchers.test.ts | 18 +++- .../session-reference-service.test.ts | 83 ++++++++++++++++++- .../src/services/session-reference-service.ts | 19 ++++- 5 files changed, 120 insertions(+), 13 deletions(-) diff --git a/docs/superpowers/plans/2026-07-17-at-session-reference.md b/docs/superpowers/plans/2026-07-17-at-session-reference.md index a2297fd2c97..258726f2dfe 100644 --- a/docs/superpowers/plans/2026-07-17-at-session-reference.md +++ b/docs/superpowers/plans/2026-07-17-at-session-reference.md @@ -322,10 +322,7 @@ export class SessionReferenceService { } const body = (truncated ? '[earlier turns omitted]\n' : '') + kept.join('\n'); - const title = - resumed.conversation.messages.length > 0 - ? sessionId // caller supplies a friendlier title in Task 3; id fallback here - : sessionId; + 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)` diff --git a/packages/cli/src/config/keyBindings.ts b/packages/cli/src/config/keyBindings.ts index 6fbd1d45963..612f423b471 100644 --- a/packages/cli/src/config/keyBindings.ts +++ b/packages/cli/src/config/keyBindings.ts @@ -188,8 +188,12 @@ export const defaultKeyBindings: KeyBindingConfig = { { key: 'n', ctrl: true }, ], // Completion category tab switching (for the tabbed @ completion UI). - [Command.COMPLETION_TAB_LEFT]: [{ key: 'left', shift: false }], - [Command.COMPLETION_TAB_RIGHT]: [{ key: 'right', shift: false }], + [Command.COMPLETION_TAB_LEFT]: [ + { key: 'left', shift: false, ctrl: false, command: false }, + ], + [Command.COMPLETION_TAB_RIGHT]: [ + { key: 'right', shift: false, ctrl: false, command: false }, + ], // Text input // Must also exclude shift to allow shift+enter for newline diff --git a/packages/cli/src/ui/keyMatchers.test.ts b/packages/cli/src/ui/keyMatchers.test.ts index 50e46fb33b3..735370525d5 100644 --- a/packages/cli/src/ui/keyMatchers.test.ts +++ b/packages/cli/src/ui/keyMatchers.test.ts @@ -46,9 +46,9 @@ describe('keyMatchers', () => { [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.name === 'left' && !key.shift && !key.ctrl && !key.meta, [Command.COMPLETION_TAB_RIGHT]: (key: Key) => - key.name === 'right' && !key.shift, + 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, @@ -239,12 +239,22 @@ describe('keyMatchers', () => { { command: Command.COMPLETION_TAB_LEFT, positive: [createKey('left')], - negative: [createKey('left', { shift: true }), createKey('right')], + negative: [ + createKey('left', { shift: true }), + createKey('left', { ctrl: true }), + createKey('left', { meta: true }), + createKey('right'), + ], }, { command: Command.COMPLETION_TAB_RIGHT, positive: [createKey('right')], - negative: [createKey('right', { shift: true }), createKey('left')], + negative: [ + createKey('right', { shift: true }), + createKey('right', { ctrl: true }), + createKey('right', { meta: true }), + createKey('left'), + ], }, // Text input diff --git a/packages/core/src/services/session-reference-service.test.ts b/packages/core/src/services/session-reference-service.test.ts index 77dff812f81..eb93b6a63e9 100644 --- a/packages/core/src/services/session-reference-service.test.ts +++ b/packages/core/src/services/session-reference-service.test.ts @@ -179,7 +179,7 @@ describe('SessionReferenceService', () => { }, ]), ); - const res = await svc.resolve('s1', { budgetTokens: 50 }); + 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]'); @@ -197,7 +197,7 @@ describe('SessionReferenceService', () => { }, })); const svc = makeSvc(fakeResumed(many)); - const res = await svc.resolve('s1', { budgetTokens: 200 }); + 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]'); @@ -217,3 +217,82 @@ describe('SessionReferenceService', () => { expect(res.truncated).toBe(false); }); }); + +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('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 index af4e2b4f325..e4beba6922d 100644 --- a/packages/core/src/services/session-reference-service.ts +++ b/packages/core/src/services/session-reference-service.ts @@ -71,7 +71,7 @@ export class SessionReferenceService { const lines = this.recordsToLines(records); const budget = opts.budgetTokens ?? SESSION_REF_TOKEN_BUDGET; - const title = opts.title ?? sessionId; + const title = opts.title ?? this.deriveTitle(records) ?? sessionId; const header = `--- Referenced session "${title}" (slimmed, read-only) ---`; // Budget for the header and potential truncation marker so the final // injected text stays within the caller's budget. @@ -149,6 +149,23 @@ export class SessionReferenceService { .trim(); } + private static readonly TITLE_MAX_LENGTH = 80; + + private deriveTitle(records: ChatRecord[]): string | undefined { + 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[] { From e9e4bcfc946e05e0d43defbb028c73e726ad87d7 Mon Sep 17 00:00:00 2001 From: Qwen Code Bot <qwen-code-bot@users.noreply.github.com> Date: Tue, 21 Jul 2026 20:29:20 +0000 Subject: [PATCH 21/34] fix(cli): surface real session lookup errors and test left tab switch (#7302) --- packages/cli/src/ui/components/InputPrompt.test.tsx | 5 +++++ .../cli/src/ui/hooks/atCommandProcessor.session.test.ts | 4 ++-- packages/cli/src/ui/hooks/atCommandProcessor.ts | 8 ++++---- 3 files changed, 11 insertions(+), 6 deletions(-) diff --git a/packages/cli/src/ui/components/InputPrompt.test.tsx b/packages/cli/src/ui/components/InputPrompt.test.tsx index 5d556968b77..5cf6ddb7d2e 100644 --- a/packages/cli/src/ui/components/InputPrompt.test.tsx +++ b/packages/cli/src/ui/components/InputPrompt.test.tsx @@ -2605,6 +2605,11 @@ describe('InputPrompt', () => { await wait(); expect(switchCategory).toHaveBeenCalledWith(1); + + stdin.write('\x1b[D'); // left arrow + await wait(); + + expect(switchCategory).toHaveBeenCalledWith(-1); unmount(); }); diff --git a/packages/cli/src/ui/hooks/atCommandProcessor.session.test.ts b/packages/cli/src/ui/hooks/atCommandProcessor.session.test.ts index 85f14fef0c0..4ad8ea81120 100644 --- a/packages/cli/src/ui/hooks/atCommandProcessor.session.test.ts +++ b/packages/cli/src/ui/hooks/atCommandProcessor.session.test.ts @@ -193,7 +193,7 @@ describe('handleAtCommand @session:', () => { (d) => d.name === 'Referenced Session', ); expect(card).toBeDefined(); - expect(card!.resultDisplay).toContain('I/O error'); + expect(card!.resultDisplay).toContain('EACCES'); expect(mockResolve).not.toHaveBeenCalled(); }); @@ -216,7 +216,7 @@ describe('handleAtCommand @session:', () => { (d) => d.name === 'Referenced Session', ); expect(card).toBeDefined(); - expect(card!.resultDisplay).toContain('I/O error'); + expect(card!.resultDisplay).toContain('corrupted JSONL'); }); it('deduplicates cross-form refs (UUID + title) resolving to the same session', async () => { diff --git a/packages/cli/src/ui/hooks/atCommandProcessor.ts b/packages/cli/src/ui/hooks/atCommandProcessor.ts index 20ea495018c..7a1843b17bc 100644 --- a/packages/cli/src/ui/hooks/atCommandProcessor.ts +++ b/packages/cli/src/ui/hooks/atCommandProcessor.ts @@ -654,8 +654,8 @@ export async function resolveAtCommandQuery({ matches = await new SessionService( config.getProjectRoot(), ).findSessionsByTitle(ref.title); - } catch { - const reason = `Could not look up sessions matching "@${originalAtPath.substring(1)}" (I/O error); try a session id instead.`; + } 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, @@ -728,8 +728,8 @@ export async function resolveAtCommandQuery({ resolved = await new SessionReferenceService( config.getProjectRoot(), ).resolve(sessionId, ref.title ? { title: ref.title } : {}); - } catch { - const reason = `Failed to load session "${sessionId}" (I/O error); the transcript may be corrupted or unreadable.`; + } catch (error: unknown) { + const reason = `Failed to load session "${sessionId}" (${getErrorMessage(error)}); the transcript may be corrupted or unreadable.`; onDebugMessage(reason); scopedMentionEntries.push({ originalAtPath, From 58f29ea0d136c9fcebc184e9eb2460a2acd99afd Mon Sep 17 00:00:00 2001 From: Qwen Code Bot <qwen-code-bot@users.noreply.github.com> Date: Tue, 21 Jul 2026 22:19:17 +0000 Subject: [PATCH 22/34] fix(cli): reset suggestion indices when active category tab disappears (#7302) --- .../cli/src/ui/hooks/useCompletion.test.ts | 21 +++++++++++++++++++ packages/cli/src/ui/hooks/useCompletion.ts | 2 ++ 2 files changed, 23 insertions(+) diff --git a/packages/cli/src/ui/hooks/useCompletion.test.ts b/packages/cli/src/ui/hooks/useCompletion.test.ts index 8bc369dde39..532ad4048e8 100644 --- a/packages/cli/src/ui/hooks/useCompletion.test.ts +++ b/packages/cli/src/ui/hooks/useCompletion.test.ts @@ -295,5 +295,26 @@ describe('useCompletion', () => { }); 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); + }); }); }); diff --git a/packages/cli/src/ui/hooks/useCompletion.ts b/packages/cli/src/ui/hooks/useCompletion.ts index 3edffa8ce57..fb5e5209e39 100644 --- a/packages/cli/src/ui/hooks/useCompletion.ts +++ b/packages/cli/src/ui/hooks/useCompletion.ts @@ -102,6 +102,8 @@ export function useCompletion( useEffect(() => { if (!availableCategories.includes(activeCategory)) { setActiveCategory('all'); + setActiveSuggestionIndex(0); + setVisibleStartIndex(0); } }, [availableCategories, activeCategory]); From d7eb219d4bba996965c1a16b5843dd33fb9df5a5 Mon Sep 17 00:00:00 2001 From: Qwen Code Bot <qwen-code-bot@users.noreply.github.com> Date: Wed, 22 Jul 2026 00:03:57 +0000 Subject: [PATCH 23/34] fix(cli): address review feedback on session refs docs and tests (#7302) --- .../plans/2026-07-17-at-session-reference.md | 111 +++++++++--------- .../2026-07-17-at-session-reference-design.md | 6 +- .../cli/src/ui/hooks/useAtCompletion.test.ts | 29 +++++ .../session-reference-service.test.ts | 24 ++++ 4 files changed, 114 insertions(+), 56 deletions(-) diff --git a/docs/superpowers/plans/2026-07-17-at-session-reference.md b/docs/superpowers/plans/2026-07-17-at-session-reference.md index 258726f2dfe..6290e2305c8 100644 --- a/docs/superpowers/plans/2026-07-17-at-session-reference.md +++ b/docs/superpowers/plans/2026-07-17-at-session-reference.md @@ -4,7 +4,7 @@ **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 (`sessionMentionRef`); 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. +**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. @@ -21,12 +21,12 @@ --- -### Task 1: `sessionMentionRef` — parse/build/validate `@session:` refs +### Task 1: `session-mention-ref` — parse/build/validate `@session:` refs **Files:** -- Create: `packages/cli/src/ui/hooks/sessionMentionRef.ts` -- Test: `packages/cli/src/ui/hooks/sessionMentionRef.test.ts` +- Create: `packages/cli/src/ui/hooks/session-mention-ref.ts` +- Test: `packages/cli/src/ui/hooks/session-mention-ref.test.ts` **Interfaces:** @@ -43,14 +43,14 @@ Note on `@`: mirror `extension-mention-ref.ts` — `buildExtensionRef` returns t - [ ] **Step 1: Write the failing test** ```ts -// packages/cli/src/ui/hooks/sessionMentionRef.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 './sessionMentionRef.js'; +} from './session-mention-ref.js'; const UUID = '3f2504e0-4f89-41d3-9a0c-0305e82c3301'; @@ -89,13 +89,13 @@ describe('sessionMentionRef', () => { - [ ] **Step 2: Run test to verify it fails** -Run: `npx vitest run packages/cli/src/ui/hooks/sessionMentionRef.test.ts` -Expected: FAIL — `Cannot find module './sessionMentionRef.js'`. +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/sessionMentionRef.ts +// packages/cli/src/ui/hooks/session-mention-ref.ts export const SESSION_MENTION_PREFIX = 'session:'; const UUID_RE = @@ -124,13 +124,13 @@ export function buildSessionRef(idOrTitle: string): string { - [ ] **Step 4: Run test to verify it passes** -Run: `npx vitest run packages/cli/src/ui/hooks/sessionMentionRef.test.ts` +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/sessionMentionRef.ts packages/cli/src/ui/hooks/sessionMentionRef.test.ts +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" ``` @@ -140,9 +140,9 @@ git commit -m "feat(cli): add @session: mention ref parser" **Files:** -- Create: `packages/core/src/services/sessionReferenceService.ts` -- Test: `packages/core/src/services/sessionReferenceService.test.ts` -- Modify (export barrel): `packages/core/src/index.ts` (add `export * from './services/sessionReferenceService.js';` alongside existing service exports) +- 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:** @@ -150,7 +150,7 @@ git commit -m "feat(cli): add @session: mention ref parser" - 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(ref: { id?: string; title?: string }, opts?: { budgetTokens?: number }): Promise<SlimmedSessionReference | { notFound: true } | { ambiguous: true; count: number }> }` + - `class SessionReferenceService { constructor(cwd: string); resolve(sessionId: string, opts?: { budgetTokens?: number; title?: string }): Promise<SlimmedSessionReference | { notFound: true }> }` Design notes for the implementer: @@ -159,19 +159,12 @@ Design notes for the implementer: - 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`. -Revised Produces (authoritative): - -```ts -resolve(sessionId: string, opts?: { budgetTokens?: number }): - Promise<SlimmedSessionReference | { notFound: true }> -``` - - [ ] **Step 1: Write the failing test** ```ts -// packages/core/src/services/sessionReferenceService.test.ts +// packages/core/src/services/session-reference-service.test.ts import { describe, it, expect, vi } from 'vitest'; -import { SessionReferenceService } from './sessionReferenceService.js'; +import { SessionReferenceService } from './session-reference-service.js'; import type { ResumedSessionData } from './sessionService.js'; function fakeResumed(messages: unknown[]): ResumedSessionData { @@ -267,13 +260,13 @@ describe('SessionReferenceService', () => { - [ ] **Step 2: Run test to verify it fails** -Run: `npx vitest run packages/core/src/services/sessionReferenceService.test.ts` -Expected: FAIL — `Cannot find module './sessionReferenceService.js'`. +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/sessionReferenceService.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'; @@ -411,7 +404,7 @@ export class SessionReferenceService { - [ ] **Step 4: Run test to verify it passes** -Run: `npx vitest run packages/core/src/services/sessionReferenceService.test.ts` +Run: `npx vitest run packages/core/src/services/session-reference-service.test.ts` Expected: PASS (4 tests). - [ ] **Step 5: Add barrel export + typecheck** @@ -419,7 +412,7 @@ Expected: PASS (4 tests). Add to `packages/core/src/index.ts` (near other `./services/*` exports): ```ts -export * from './services/sessionReferenceService.js'; +export * from './services/session-reference-service.js'; ``` Run: `npx tsc --noEmit -p packages/core/tsconfig.json` @@ -428,7 +421,7 @@ Expected: no errors in the new file. - [ ] **Step 6: Commit** ```bash -git add packages/core/src/services/sessionReferenceService.ts packages/core/src/services/sessionReferenceService.test.ts packages/core/src/index.ts +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" ``` @@ -454,6 +447,7 @@ Behavior: 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** @@ -508,7 +502,7 @@ Expected: FAIL — assertion fails (session text not injected) because the branc In `atCommandProcessor.ts`, add imports at the top: ```ts -import { parseSessionRef } from './sessionMentionRef.js'; +import { parseSessionRef } from './session-mention-ref.js'; import { SessionReferenceService } from '@qwen-code/qwen-code-core'; ``` @@ -539,22 +533,33 @@ if (sessionRef) { continue; // token already retained as literal text } } - const ref = await new SessionReferenceService(config.getWorkingDir()).resolve( - sessionId!, - ); - if ('notFound' in ref) { + 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: `Session "${sessionId}" not found.` }, + { + type: MessageType.INFO, + text: `Failed to load session "${sessionId}".`, + }, 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; } ``` @@ -586,9 +591,9 @@ git commit -m "feat(cli): inject slimmed prior-session context on @session: ment **Files:** - Modify: `packages/cli/src/ui/components/SuggestionsDisplay.tsx:19-45` (add `category` to `Suggestion`, add `SuggestionCategory` type) -- Create: `packages/cli/src/ui/hooks/sessionCompletion.ts` (producer, mirrors `extension-mention-ref.ts`) +- 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/sessionCompletion.test.ts` +- Test: `packages/cli/src/ui/hooks/session-completion.test.ts` **Interfaces:** @@ -616,7 +621,7 @@ and inside `Suggestion`: Producer test: ```ts -// packages/cli/src/ui/hooks/sessionCompletion.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) => { @@ -645,7 +650,7 @@ vi.mock('@qwen-code/qwen-code-core', async (orig) => { }; }); -import { getSessionSuggestions } from './sessionCompletion.js'; +import { getSessionSuggestions } from './session-completion.js'; describe('getSessionSuggestions', () => { it('maps sessions to category:session suggestions with @session: values', async () => { @@ -670,16 +675,16 @@ describe('getSessionSuggestions', () => { - [ ] **Step 2: Run test to verify it fails** -Run: `npx vitest run packages/cli/src/ui/hooks/sessionCompletion.test.ts` -Expected: FAIL — `Cannot find module './sessionCompletion.js'`. +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/sessionCompletion.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 } from './sessionMentionRef.js'; +import { buildSessionRef } from './session-mention-ref.js'; const MAX_SESSION_SUGGESTIONS = 20; @@ -720,12 +725,12 @@ export async function getSessionSuggestions( - [ ] **Step 4: Run test to verify it passes** -Run: `npx vitest run packages/cli/src/ui/hooks/sessionCompletion.test.ts` +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 './sessionCompletion.js';` +- Add import: `import { getSessionSuggestions } from './session-completion.js';` - Tag file results with `category: 'file'` at the `fileSuggestions` map (~line 486): ```ts @@ -763,7 +768,7 @@ Expected: PASS (existing) + no type errors. - [ ] **Step 7: Commit** ```bash -git add packages/cli/src/ui/components/SuggestionsDisplay.tsx packages/cli/src/ui/hooks/sessionCompletion.ts packages/cli/src/ui/hooks/sessionCompletion.test.ts packages/cli/src/ui/hooks/useAtCompletion.ts packages/cli/src/ui/hooks/extension-mention-ref.ts +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" ``` 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 index 2313039f6e0..3b7f7927919 100644 --- a/docs/superpowers/specs/2026-07-17-at-session-reference-design.md +++ b/docs/superpowers/specs/2026-07-17-at-session-reference-design.md @@ -125,12 +125,12 @@ text with a surfaced note (never silently dropped). - **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` = relative time / message - count. Shown on **bare `@`** (like extensions) and filtered by pattern. + 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. Tab counts shown per tab. + 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** diff --git a/packages/cli/src/ui/hooks/useAtCompletion.test.ts b/packages/cli/src/ui/hooks/useAtCompletion.test.ts index 42e18587005..a5087f1ced7 100644 --- a/packages/cli/src/ui/hooks/useAtCompletion.test.ts +++ b/packages/cli/src/ui/hooks/useAtCompletion.test.ts @@ -997,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', () => { diff --git a/packages/core/src/services/session-reference-service.test.ts b/packages/core/src/services/session-reference-service.test.ts index eb93b6a63e9..1f0076cf2a5 100644 --- a/packages/core/src/services/session-reference-service.test.ts +++ b/packages/core/src/services/session-reference-service.test.ts @@ -100,6 +100,30 @@ describe('SessionReferenceService', () => { expect(res.text).toContain('[tool: write_file — error]'); }); + 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 From 7d90d1fa65b576770791b2998320dbd80d778cce Mon Sep 17 00:00:00 2001 From: Qwen Code Bot <qwen-code-bot@users.noreply.github.com> Date: Wed, 22 Jul 2026 01:47:39 +0000 Subject: [PATCH 24/34] fix(cli): address review feedback on session refs and completion ordering (#7302) --- .../ui/hooks/atCommandProcessor.session.test.ts | 3 +++ packages/cli/src/ui/hooks/atCommandProcessor.ts | 3 +++ .../cli/src/ui/hooks/extension-mention-ref.test.ts | 1 + packages/cli/src/ui/hooks/useAtCompletion.ts | 14 ++++++++++---- 4 files changed, 17 insertions(+), 4 deletions(-) diff --git a/packages/cli/src/ui/hooks/atCommandProcessor.session.test.ts b/packages/cli/src/ui/hooks/atCommandProcessor.session.test.ts index 4ad8ea81120..489474aa500 100644 --- a/packages/cli/src/ui/hooks/atCommandProcessor.session.test.ts +++ b/packages/cli/src/ui/hooks/atCommandProcessor.session.test.ts @@ -241,6 +241,9 @@ describe('handleAtCommand @session:', () => { (d) => d.name === 'Referenced Session', ); expect(cards).toHaveLength(1); + expect(mockOnDebugMessage).toHaveBeenCalledWith( + expect.stringContaining('already referenced'), + ); }); it('deduplicates identical session mentions', async () => { diff --git a/packages/cli/src/ui/hooks/atCommandProcessor.ts b/packages/cli/src/ui/hooks/atCommandProcessor.ts index 7a1843b17bc..5d65dd58678 100644 --- a/packages/cli/src/ui/hooks/atCommandProcessor.ts +++ b/packages/cli/src/ui/hooks/atCommandProcessor.ts @@ -719,6 +719,9 @@ export async function resolveAtCommandQuery({ // 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); 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/useAtCompletion.ts b/packages/cli/src/ui/hooks/useAtCompletion.ts index 34947fb57a9..df1e8bba1c8 100644 --- a/packages/cli/src/ui/hooks/useAtCompletion.ts +++ b/packages/cli/src/ui/hooks/useAtCompletion.ts @@ -453,7 +453,9 @@ export function useAtCompletion(props: UseAtCompletionProps): void { if (!fileSearch.current) { // File index not ready yet; still surface non-file matches so they // don't have to wait on the crawler. - const sessionSuggestions = await sessionPromise; + 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) { @@ -503,7 +505,9 @@ export function useAtCompletion(props: UseAtCompletionProps): void { isDirectory: p.endsWith('/'), category: 'file' as const, })); - const sessionSuggestions = await sessionPromise; + const sessionSuggestions = await sessionPromise.catch( + () => [] as Suggestion[], + ); if (controller.signal.aborted || cancelled) { return; } @@ -511,15 +515,17 @@ export function useAtCompletion(props: UseAtCompletionProps): void { type: 'SEARCH_SUCCESS', payload: [ ...mcpSuggestions, - ...sessionSuggestions, ...fileSuggestions, + ...sessionSuggestions, ], }); } catch (error) { if (!(error instanceof Error && error.name === 'AbortError')) { // A file-search failure shouldn't swallow non-file matches we already // have; show those rather than dropping to an error state. - const sessionSuggestions = await sessionPromise; + const sessionSuggestions = await sessionPromise.catch( + () => [] as Suggestion[], + ); if (cancelled) { return; } From cb9341ba97028445303a4d7b349fe340d67ada2c Mon Sep 17 00:00:00 2001 From: Qwen Code Bot <qwen-code-bot@users.noreply.github.com> Date: Wed, 22 Jul 2026 12:57:03 +0000 Subject: [PATCH 25/34] docs(cli): clarify title matching semantics in session reference design (#7302) --- .../specs/2026-07-17-at-session-reference-design.md | 5 +++++ 1 file changed, 5 insertions(+) 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 index 3b7f7927919..56f0d586bb9 100644 --- a/docs/superpowers/specs/2026-07-17-at-session-reference-design.md +++ b/docs/superpowers/specs/2026-07-17-at-session-reference-design.md @@ -69,6 +69,11 @@ packages/cli/src/ui/components/ (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:<uuid>`, so the + mainline path is unaffected; a hand-typed `@session:<free text>` 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". From 5605c032ef4703a521e95b12f9633a5ec12cb8ca Mon Sep 17 00:00:00 2001 From: qwen-code-bot <qwen-code-bot@users.noreply.github.com> Date: Thu, 23 Jul 2026 08:12:34 +0000 Subject: [PATCH 26/34] test(cli): assert error card in ambiguous session title test (#7302) --- packages/cli/src/ui/hooks/atCommandProcessor.session.test.ts | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/packages/cli/src/ui/hooks/atCommandProcessor.session.test.ts b/packages/cli/src/ui/hooks/atCommandProcessor.session.test.ts index 489474aa500..9fe80ee132d 100644 --- a/packages/cli/src/ui/hooks/atCommandProcessor.session.test.ts +++ b/packages/cli/src/ui/hooks/atCommandProcessor.session.test.ts @@ -172,6 +172,11 @@ describe('handleAtCommand @session:', () => { 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 () => { From 7a54bfcbc794cd819b9d7fa4d601026c513710f4 Mon Sep 17 00:00:00 2001 From: qwen-code-ci-bot <qwen-code-ci-bot@users.noreply.github.com> Date: Thu, 23 Jul 2026 14:30:02 +0000 Subject: [PATCH 27/34] fix(core,cli): address review feedback on session refs (#7302) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Replace O(N²) budget trimming with single-pass backward accumulation (maintainer-measured 6.8s → ~80ms for 8k-record sessions) - Include header/marker overhead in approxTokens - Prefer custom_title system record in deriveTitle over first user message - Move session-ref detection before MCP resource ref to prevent collision with an MCP server literally named "session" - Handle bare @session (no colon) as empty filter in completion - Clamp activeSuggestionIndex when suggestion list shrinks - Strengthen not-found test assertion to check ToolCallStatus.Error - Fix unreachable mock state in InputPrompt tab-guard test - Add tests: custom_title derivation, overhead in approxTokens, bare session filter, index clamping, activeCategory reset --- .../src/ui/components/InputPrompt.test.tsx | 2 +- .../hooks/atCommandProcessor.session.test.ts | 4 +- .../cli/src/ui/hooks/atCommandProcessor.ts | 37 ++++++++-------- .../src/ui/hooks/session-completion.test.ts | 22 ++++++++++ .../cli/src/ui/hooks/session-completion.ts | 7 ++- .../cli/src/ui/hooks/useCompletion.test.ts | 21 +++++++++ packages/cli/src/ui/hooks/useCompletion.ts | 10 +++++ .../session-reference-service.test.ts | 37 ++++++++++++++++ .../src/services/session-reference-service.ts | 43 ++++++++++++++----- 9 files changed, 151 insertions(+), 32 deletions(-) diff --git a/packages/cli/src/ui/components/InputPrompt.test.tsx b/packages/cli/src/ui/components/InputPrompt.test.tsx index 5cf6ddb7d2e..a6b5e81a83b 100644 --- a/packages/cli/src/ui/components/InputPrompt.test.tsx +++ b/packages/cli/src/ui/components/InputPrompt.test.tsx @@ -2564,7 +2564,7 @@ describe('InputPrompt', () => { ], activeSuggestionIndex: 0, isPerfectMatch: false, - availableCategories: ['all', 'file'], + availableCategories: ['all'], switchCategory, }); props.buffer.setText('@file'); diff --git a/packages/cli/src/ui/hooks/atCommandProcessor.session.test.ts b/packages/cli/src/ui/hooks/atCommandProcessor.session.test.ts index 9fe80ee132d..c3417c9c565 100644 --- a/packages/cli/src/ui/hooks/atCommandProcessor.session.test.ts +++ b/packages/cli/src/ui/hooks/atCommandProcessor.session.test.ts @@ -24,6 +24,7 @@ vi.mock('@qwen-code/qwen-code-core', async (orig) => { }); import { handleAtCommand } from './atCommandProcessor.js'; +import { ToolCallStatus } from '../types.js'; import type { Config } from '@qwen-code/qwen-code-core'; import { FileDiscoveryService, @@ -149,7 +150,8 @@ describe('handleAtCommand @session:', () => { // an error card explains the miss expect( result.toolDisplays?.some( - (d) => d.name === 'Referenced Session' && d.status !== undefined, + (d) => + d.name === 'Referenced Session' && d.status === ToolCallStatus.Error, ), ).toBe(true); }); diff --git a/packages/cli/src/ui/hooks/atCommandProcessor.ts b/packages/cli/src/ui/hooks/atCommandProcessor.ts index 5d65dd58678..9dad69f6ef4 100644 --- a/packages/cli/src/ui/hooks/atCommandProcessor.ts +++ b/packages/cli/src/ui/hooks/atCommandProcessor.ts @@ -285,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 @@ -325,24 +344,6 @@ export async function resolveAtCommandQuery({ continue; } - // Session reference (`@session:<id|title>`): detected BEFORE filesystem - // resolution so the ':' in the token isn't mistaken for a path. 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; - } - // Check if path should be ignored based on filtering options const workspaceContext = config.getWorkspaceContext(); diff --git a/packages/cli/src/ui/hooks/session-completion.test.ts b/packages/cli/src/ui/hooks/session-completion.test.ts index a42eaf4381b..b3a5392cf10 100644 --- a/packages/cli/src/ui/hooks/session-completion.test.ts +++ b/packages/cli/src/ui/hooks/session-completion.test.ts @@ -96,6 +96,28 @@ describe('getSessionSuggestions', () => { 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', ''); diff --git a/packages/cli/src/ui/hooks/session-completion.ts b/packages/cli/src/ui/hooks/session-completion.ts index 6acd1494ccf..8a472eba351 100644 --- a/packages/cli/src/ui/hooks/session-completion.ts +++ b/packages/cli/src/ui/hooks/session-completion.ts @@ -34,9 +34,14 @@ export async function getSessionSuggestions( return []; } + // 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; + : pattern.toLowerCase() === 'session' + ? '' + : pattern; const needle = stripped.trim().toLowerCase(); return items .map((s) => { diff --git a/packages/cli/src/ui/hooks/useCompletion.test.ts b/packages/cli/src/ui/hooks/useCompletion.test.ts index 532ad4048e8..7935c0c747f 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', () => { @@ -316,5 +317,25 @@ describe('useCompletion', () => { 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); + }); }); }); diff --git a/packages/cli/src/ui/hooks/useCompletion.ts b/packages/cli/src/ui/hooks/useCompletion.ts index fb5e5209e39..cde720ef5b9 100644 --- a/packages/cli/src/ui/hooks/useCompletion.ts +++ b/packages/cli/src/ui/hooks/useCompletion.ts @@ -107,6 +107,16 @@ export function useCompletion( } }, [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, + ); + }, [suggestions.length]); + const switchCategory = useCallback( (direction: 1 | -1) => { setActiveCategory((cur) => { diff --git a/packages/core/src/services/session-reference-service.test.ts b/packages/core/src/services/session-reference-service.test.ts index 1f0076cf2a5..af7d0ab1a37 100644 --- a/packages/core/src/services/session-reference-service.test.ts +++ b/packages/core/src/services/session-reference-service.test.ts @@ -240,6 +240,23 @@ describe('SessionReferenceService', () => { 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 + omission marker overhead, + // not just the body lines. + const bodyOnly = svc['estimate'](['User: hello']); + expect(res.meta.approxTokens).toBeGreaterThan(bodyOnly); + }); }); describe('title derivation', () => { @@ -262,6 +279,26 @@ describe('title derivation', () => { 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('truncates a long first user message to 80 chars', async () => { const long = 'A'.repeat(120); const svc = makeSvc( diff --git a/packages/core/src/services/session-reference-service.ts b/packages/core/src/services/session-reference-service.ts index e4beba6922d..bedf43267e6 100644 --- a/packages/core/src/services/session-reference-service.ts +++ b/packages/core/src/services/session-reference-service.ts @@ -34,6 +34,10 @@ 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 @@ -73,19 +77,27 @@ export class SessionReferenceService { const title = opts.title ?? this.deriveTitle(records) ?? sessionId; const header = `--- Referenced session "${title}" (slimmed, read-only) ---`; - // Budget for the header and potential truncation marker so the final - // injected text stays within the caller's budget. const overhead = this.estimate([header, '[earlier turns omitted]']); - const kept = [...lines]; - let truncated = false; - // Tail-retention: drop the oldest lines first until under budget, but - // always keep at least the newest line so an over-budget final turn still - // yields content (rather than collapsing to just the omission marker). - while (kept.length > 1 && this.estimate(kept) + overhead > budget) { - kept.shift(); - truncated = true; + // 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 = overhead; + 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 body = (truncated ? '[earlier turns omitted]\n' : '') + kept.join('\n'); const text = @@ -99,7 +111,7 @@ export class SessionReferenceService { sessionId, title, messageCount: records.length, - approxTokens: this.estimate(kept), + approxTokens: this.estimate(kept) + overhead, }, truncated, }; @@ -152,6 +164,15 @@ export class SessionReferenceService { 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. + for (const rec of records) { + if (rec.type === 'system' && rec.subtype === 'custom_title') { + const payload = rec.systemPayload as CustomTitlePayload | undefined; + if (payload?.customTitle) return payload.customTitle; + } + } for (const rec of records) { if (rec.type !== 'user') continue; const text = this.visibleText(rec.message); From 65023a279ec3663349e5b6fd07d67891de82e91e Mon Sep 17 00:00:00 2001 From: Qwen Code Bot <qwen-code-bot@users.noreply.github.com> Date: Thu, 23 Jul 2026 16:29:39 +0000 Subject: [PATCH 28/34] fix(core,cli): address review feedback on session refs (#7302) - Fix approxTokens to exclude omission marker cost when not truncated - Clamp visibleStartIndex when suggestion list shrinks to prevent empty dropdown - Document merge order invariant for session suggestions in @ completion --- packages/cli/src/ui/hooks/useAtCompletion.ts | 2 + .../cli/src/ui/hooks/useCompletion.test.ts | 21 ++++++++++ packages/cli/src/ui/hooks/useCompletion.ts | 5 +++ .../session-reference-service.test.ts | 42 ++++++++++++++++++- .../src/services/session-reference-service.ts | 4 +- 5 files changed, 71 insertions(+), 3 deletions(-) diff --git a/packages/cli/src/ui/hooks/useAtCompletion.ts b/packages/cli/src/ui/hooks/useAtCompletion.ts index df1e8bba1c8..f84a7612243 100644 --- a/packages/cli/src/ui/hooks/useAtCompletion.ts +++ b/packages/cli/src/ui/hooks/useAtCompletion.ts @@ -448,6 +448,8 @@ export function useAtCompletion(props: UseAtCompletionProps): void { // 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) { diff --git a/packages/cli/src/ui/hooks/useCompletion.test.ts b/packages/cli/src/ui/hooks/useCompletion.test.ts index 7935c0c747f..5c0929eaca8 100644 --- a/packages/cli/src/ui/hooks/useCompletion.test.ts +++ b/packages/cli/src/ui/hooks/useCompletion.test.ts @@ -337,5 +337,26 @@ describe('useCompletion', () => { }); 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 cde720ef5b9..a808f0c090b 100644 --- a/packages/cli/src/ui/hooks/useCompletion.ts +++ b/packages/cli/src/ui/hooks/useCompletion.ts @@ -115,6 +115,11 @@ export function useCompletion( ? suggestions.length - 1 : prev, ); + setVisibleStartIndex((prev) => + prev >= suggestions.length + ? Math.max(0, suggestions.length - MAX_SUGGESTIONS_TO_SHOW) + : prev, + ); }, [suggestions.length]); const switchCategory = useCallback( diff --git a/packages/core/src/services/session-reference-service.test.ts b/packages/core/src/services/session-reference-service.test.ts index af7d0ab1a37..a19b4cee5a7 100644 --- a/packages/core/src/services/session-reference-service.test.ts +++ b/packages/core/src/services/session-reference-service.test.ts @@ -252,11 +252,49 @@ describe('SessionReferenceService', () => { ); const res = await svc.resolve('s1', { title: 'Test' }); if ('notFound' in res) throw new Error('unexpected'); - // approxTokens must account for the header + omission marker overhead, - // not just the body lines. + // 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', () => { diff --git a/packages/core/src/services/session-reference-service.ts b/packages/core/src/services/session-reference-service.ts index bedf43267e6..02a3679d793 100644 --- a/packages/core/src/services/session-reference-service.ts +++ b/packages/core/src/services/session-reference-service.ts @@ -111,7 +111,9 @@ export class SessionReferenceService { sessionId, title, messageCount: records.length, - approxTokens: this.estimate(kept) + overhead, + approxTokens: + this.estimate(kept) + + (truncated ? overhead : this.estimate([header])), }, truncated, }; From c995c6df81a8a22e376f695138ba1d45fa50a25d Mon Sep 17 00:00:00 2001 From: Qwen Code Bot <qwen-code-bot@users.noreply.github.com> Date: Thu, 23 Jul 2026 18:13:27 +0000 Subject: [PATCH 29/34] fix(core,cli): address review feedback on session refs (#7302) --- .../plans/2026-07-17-at-session-reference.md | 47 +++++++++++-------- .../cli/src/ui/components/InputPrompt.tsx | 12 ++++- .../session-reference-service.test.ts | 26 ++++++++++ .../src/services/session-reference-service.ts | 7 ++- 4 files changed, 69 insertions(+), 23 deletions(-) diff --git a/docs/superpowers/plans/2026-07-17-at-session-reference.md b/docs/superpowers/plans/2026-07-17-at-session-reference.md index 6290e2305c8..9a1111048b5 100644 --- a/docs/superpowers/plans/2026-07-17-at-session-reference.md +++ b/docs/superpowers/plans/2026-07-17-at-session-reference.md @@ -513,24 +513,28 @@ const sessionRef = parseSessionRef(pathName); if (sessionRef) { let sessionId = sessionRef.id; if (!sessionId && sessionRef.title) { - 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 + 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 } } try { @@ -701,7 +705,12 @@ export async function getSessionSuggestions( } catch { return []; // I/O failure → session tab simply empty } - const needle = pattern.trim().toLowerCase(); + 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; diff --git a/packages/cli/src/ui/components/InputPrompt.tsx b/packages/cli/src/ui/components/InputPrompt.tsx index 81ba39a0a73..824cae9dc47 100644 --- a/packages/cli/src/ui/components/InputPrompt.tsx +++ b/packages/cli/src/ui/components/InputPrompt.tsx @@ -2247,10 +2247,18 @@ export const InputPrompt: React.FC<InputPromptProps> = ({ expandedIndex={expandedSuggestionIndex} mouseEnabled={mouseInteractionsEnabled} activeCategory={ - suggestionsFromExport ? undefined : completion.activeCategory + suggestionsFromExport || + commandSearchActive || + reverseSearchActive + ? undefined + : completion.activeCategory } availableCategories={ - suggestionsFromExport ? undefined : completion.availableCategories + suggestionsFromExport || + commandSearchActive || + reverseSearchActive + ? undefined + : completion.availableCategories } onHoverIndex={ suggestionsFromExport ? undefined : handleSuggestionHover diff --git a/packages/core/src/services/session-reference-service.test.ts b/packages/core/src/services/session-reference-service.test.ts index a19b4cee5a7..e699faca180 100644 --- a/packages/core/src/services/session-reference-service.test.ts +++ b/packages/core/src/services/session-reference-service.test.ts @@ -337,6 +337,32 @@ describe('title derivation', () => { 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( diff --git a/packages/core/src/services/session-reference-service.ts b/packages/core/src/services/session-reference-service.ts index 02a3679d793..cabd594632f 100644 --- a/packages/core/src/services/session-reference-service.ts +++ b/packages/core/src/services/session-reference-service.ts @@ -168,13 +168,16 @@ export class SessionReferenceService { 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. + // 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) return payload.customTitle; + 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); From 1e32b91d1e506c3e002a229d5c13b89bc6f776f5 Mon Sep 17 00:00:00 2001 From: Qwen Code Bot <qwen-code-bot@users.noreply.github.com> Date: Thu, 23 Jul 2026 20:13:16 +0000 Subject: [PATCH 30/34] fix(core): address review feedback on session refs (#7302) --- .../session-reference-service.test.ts | 19 +++++++++++++++++++ .../src/services/session-reference-service.ts | 9 +++++---- 2 files changed, 24 insertions(+), 4 deletions(-) diff --git a/packages/core/src/services/session-reference-service.test.ts b/packages/core/src/services/session-reference-service.test.ts index e699faca180..13b04c81dc7 100644 --- a/packages/core/src/services/session-reference-service.test.ts +++ b/packages/core/src/services/session-reference-service.test.ts @@ -100,6 +100,25 @@ describe('SessionReferenceService', () => { 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('surfaces an error tool_result that has no functionResponse parts', async () => { const svc = makeSvc( fakeResumed([ diff --git a/packages/core/src/services/session-reference-service.ts b/packages/core/src/services/session-reference-service.ts index cabd594632f..a7cb3077756 100644 --- a/packages/core/src/services/session-reference-service.ts +++ b/packages/core/src/services/session-reference-service.ts @@ -25,8 +25,7 @@ export interface SlimmedSessionReference { truncated: boolean; } -interface FunctionCallPart { - functionCall?: { name?: string }; +interface FunctionResponsePart { functionResponse?: { name?: string }; } @@ -146,7 +145,9 @@ export class SessionReferenceService { // (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 status = rec.toolCallResult?.error ? 'error' : 'ok'; + const status = rec.toolCallResult?.error + ? 'error' + : (rec.toolCallResult?.status ?? 'ok'); out.push(`[tool: ${name} — ${status}]`); } // system records contribute nothing @@ -197,7 +198,7 @@ export class SessionReferenceService { private functionResponseNames(message?: Content): string[] { if (!message?.parts) return []; return message.parts - .map((p: Part) => (p as FunctionCallPart).functionResponse?.name) + .map((p: Part) => (p as FunctionResponsePart).functionResponse?.name) .filter( (name): name is string => typeof name === 'string' && name.length > 0, ); From 4f655fb8cbaedca28bb7888e0236fa802a7f3c12 Mon Sep 17 00:00:00 2001 From: qwen-code-ci-bot <qwen-code-ci-bot@users.noreply.github.com> Date: Thu, 23 Jul 2026 22:04:32 +0000 Subject: [PATCH 31/34] fix(core,cli): address review feedback on session refs (#7302) --- .../plans/2026-07-17-at-session-reference.md | 10 ++++++++ .../cli/src/ui/hooks/useCompletion.test.ts | 24 +++++++++++++++++++ 2 files changed, 34 insertions(+) diff --git a/docs/superpowers/plans/2026-07-17-at-session-reference.md b/docs/superpowers/plans/2026-07-17-at-session-reference.md index 9a1111048b5..55a5fe60c1c 100644 --- a/docs/superpowers/plans/2026-07-17-at-session-reference.md +++ b/docs/superpowers/plans/2026-07-17-at-session-reference.md @@ -239,6 +239,8 @@ describe('SessionReferenceService', () => { 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', @@ -388,6 +390,8 @@ export class SessionReferenceService { ); } +> **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) => @@ -536,6 +540,8 @@ if (sessionRef) { } 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( @@ -901,6 +907,8 @@ const visible = 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 @@ -1034,6 +1042,8 @@ const switchCategory = useCallback( ); ``` +> **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** diff --git a/packages/cli/src/ui/hooks/useCompletion.test.ts b/packages/cli/src/ui/hooks/useCompletion.test.ts index 5c0929eaca8..3ec6efda302 100644 --- a/packages/cli/src/ui/hooks/useCompletion.test.ts +++ b/packages/cli/src/ui/hooks/useCompletion.test.ts @@ -272,6 +272,30 @@ describe('useCompletion', () => { 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(() => { From 37b9d99b9f031541ce4e0950e7d16117f99d903c Mon Sep 17 00:00:00 2001 From: Qwen Code Bot <qwen-code-bot@users.noreply.github.com> Date: Thu, 23 Jul 2026 23:43:46 +0000 Subject: [PATCH 32/34] fix(core): address review feedback on session refs (#7302) --- .../plans/2026-07-17-at-session-reference.md | 8 ++++++-- .../session-reference-service.test.ts | 19 +++++++++++++++++++ .../src/services/session-reference-service.ts | 5 ++++- 3 files changed, 29 insertions(+), 3 deletions(-) diff --git a/docs/superpowers/plans/2026-07-17-at-session-reference.md b/docs/superpowers/plans/2026-07-17-at-session-reference.md index 55a5fe60c1c..d798ba2c863 100644 --- a/docs/superpowers/plans/2026-07-17-at-session-reference.md +++ b/docs/superpowers/plans/2026-07-17-at-session-reference.md @@ -352,8 +352,9 @@ export class SessionReferenceService { 'tool'; const status = rec.toolCallResult?.status ?? 'ok'; out.push(`[tool: ${name} — ${status}]`); - continue; } + // 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}`); @@ -694,7 +695,10 @@ Expected: FAIL — `Cannot find module './session-completion.js'`. // 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 } from './session-mention-ref.js'; +import { + buildSessionRef, + SESSION_MENTION_PREFIX, +} from './session-mention-ref.js'; const MAX_SESSION_SUGGESTIONS = 20; diff --git a/packages/core/src/services/session-reference-service.test.ts b/packages/core/src/services/session-reference-service.test.ts index 13b04c81dc7..f98c76b0f20 100644 --- a/packages/core/src/services/session-reference-service.test.ts +++ b/packages/core/src/services/session-reference-service.test.ts @@ -119,6 +119,25 @@ describe('SessionReferenceService', () => { 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([ diff --git a/packages/core/src/services/session-reference-service.ts b/packages/core/src/services/session-reference-service.ts index a7cb3077756..997f0e1c9da 100644 --- a/packages/core/src/services/session-reference-service.ts +++ b/packages/core/src/services/session-reference-service.ts @@ -145,9 +145,12 @@ export class SessionReferenceService { // (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' - : (rec.toolCallResult?.status ?? 'ok'); + : raw === 'success' + ? 'ok' + : (raw ?? 'ok'); out.push(`[tool: ${name} — ${status}]`); } // system records contribute nothing From 74881a1e80b56897dd22ed2228e5519eb8f087ad Mon Sep 17 00:00:00 2001 From: Qwen Code Bot <qwen-code-bot@users.noreply.github.com> Date: Fri, 24 Jul 2026 01:57:44 +0000 Subject: [PATCH 33/34] fix(cli): address review feedback on session refs (#7302) --- packages/cli/src/config/keyBindings.ts | 8 ++- .../src/ui/components/InputPrompt.test.tsx | 37 ++++++++++- .../cli/src/ui/components/InputPrompt.tsx | 5 +- .../src/ui/components/SuggestionsDisplay.tsx | 4 +- .../src/ui/hooks/session-completion.test.ts | 57 +++++++++++++++- .../cli/src/ui/hooks/session-completion.ts | 65 ++++++++++++++++--- packages/cli/src/ui/keyMatchers.test.ts | 24 +++---- 7 files changed, 169 insertions(+), 31 deletions(-) diff --git a/packages/cli/src/config/keyBindings.ts b/packages/cli/src/config/keyBindings.ts index 612f423b471..833fa02e740 100644 --- a/packages/cli/src/config/keyBindings.ts +++ b/packages/cli/src/config/keyBindings.ts @@ -188,11 +188,15 @@ export const defaultKeyBindings: KeyBindingConfig = { { 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: false, command: false }, + { key: 'left', shift: false, ctrl: true, command: false }, ], [Command.COMPLETION_TAB_RIGHT]: [ - { key: 'right', shift: false, ctrl: false, command: false }, + { key: 'right', shift: false, ctrl: true, command: false }, ], // Text input diff --git a/packages/cli/src/ui/components/InputPrompt.test.tsx b/packages/cli/src/ui/components/InputPrompt.test.tsx index a6b5e81a83b..1f4c8b1655c 100644 --- a/packages/cli/src/ui/components/InputPrompt.test.tsx +++ b/packages/cli/src/ui/components/InputPrompt.test.tsx @@ -2581,7 +2581,7 @@ describe('InputPrompt', () => { unmount(); }); - it('should switch category on left/right when availableCategories > 2', async () => { + it('should switch category on Ctrl+left/right when availableCategories > 2', async () => { const switchCategory = vi.fn(); mockedUseCommandCompletion.mockReturnValue({ ...mockCommandCompletion, @@ -2601,18 +2601,49 @@ describe('InputPrompt', () => { const { stdin, unmount } = renderWithProviders(<InputPrompt {...props} />); await wait(); - stdin.write('\x1b[C'); // right arrow + stdin.write('\x1b[1;5C'); // Ctrl+right arrow await wait(); expect(switchCategory).toHaveBeenCalledWith(1); - stdin.write('\x1b[D'); // left arrow + 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 824cae9dc47..d860683d82d 100644 --- a/packages/cli/src/ui/components/InputPrompt.tsx +++ b/packages/cli/src/ui/components/InputPrompt.tsx @@ -1398,8 +1398,9 @@ export const InputPrompt: React.FC<InputPromptProps> = ({ if (showCompletionSuggestions) { // Category tab switching for the tabbed `@` completion UI. Only consume - // ←/→ when there is more than one tab, so plain file/slash completion - // leaves left/right cursor movement in the buffer untouched. + // Ctrl+←/→ (per the COMPLETION_TAB_* bindings) and only when there is + // more than one tab. 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); diff --git a/packages/cli/src/ui/components/SuggestionsDisplay.tsx b/packages/cli/src/ui/components/SuggestionsDisplay.tsx index cd73315b89c..9b3f1ba4be6 100644 --- a/packages/cli/src/ui/components/SuggestionsDisplay.tsx +++ b/packages/cli/src/ui/components/SuggestionsDisplay.tsx @@ -188,7 +188,9 @@ export function SuggestionsDisplay({ ); })} <Box marginLeft={2}> - <Text color={theme.text.secondary}>{t('(←/→ to switch)')}</Text> + <Text color={theme.text.secondary}> + {t('(Ctrl+←/→ to switch)')} + </Text> </Box> </Box> )} diff --git a/packages/cli/src/ui/hooks/session-completion.test.ts b/packages/cli/src/ui/hooks/session-completion.test.ts index b3a5392cf10..6f357f74a86 100644 --- a/packages/cli/src/ui/hooks/session-completion.test.ts +++ b/packages/cli/src/ui/hooks/session-completion.test.ts @@ -4,7 +4,7 @@ * SPDX-License-Identifier: Apache-2.0 */ -import { describe, it, expect, vi } from 'vitest'; +import { describe, it, expect, vi, beforeEach } from 'vitest'; const mockListSessions = vi.fn(); @@ -18,7 +18,15 @@ vi.mock('@qwen-code/qwen-code-core', async (orig) => { }; }); -import { getSessionSuggestions } from './session-completion.js'; +import { + getSessionSuggestions, + __resetSessionSuggestionCacheForTest, +} from './session-completion.js'; + +beforeEach(() => { + mockListSessions.mockReset(); + __resetSessionSuggestionCacheForTest(); +}); describe('getSessionSuggestions', () => { it('maps sessions to category:session suggestions with @session: values', async () => { @@ -123,4 +131,49 @@ describe('getSessionSuggestions', () => { 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 index 8a472eba351..bcdd40d1c10 100644 --- a/packages/cli/src/ui/hooks/session-completion.ts +++ b/packages/cli/src/ui/hooks/session-completion.ts @@ -5,6 +5,7 @@ */ 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, @@ -15,24 +16,70 @@ import { t } from '../../i18n/index.js'; const MAX_SESSION_SUGGESTIONS = 20; /** - * Lists prior sessions for the current project as `@` completion suggestions. - * Scope is enforced by SessionService (current project only). A listing - * failure yields an empty list so the Sessions tab simply shows nothing - * rather than breaking file/MCP/extension completion. + * 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. */ -export async function getSessionSuggestions( +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, - pattern: string, -): Promise<Suggestion[]> { - let items; + 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, }); - items = res.items; + 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 diff --git a/packages/cli/src/ui/keyMatchers.test.ts b/packages/cli/src/ui/keyMatchers.test.ts index 735370525d5..6932dca55ba 100644 --- a/packages/cli/src/ui/keyMatchers.test.ts +++ b/packages/cli/src/ui/keyMatchers.test.ts @@ -46,9 +46,9 @@ describe('keyMatchers', () => { [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, + key.name === 'left' && !key.shift && key.ctrl && !key.meta, [Command.COMPLETION_TAB_RIGHT]: (key: Key) => - key.name === 'right' && !key.shift && !key.ctrl && !key.meta, + 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, @@ -238,22 +238,22 @@ describe('keyMatchers', () => { }, { command: Command.COMPLETION_TAB_LEFT, - positive: [createKey('left')], + positive: [createKey('left', { ctrl: true })], negative: [ - createKey('left', { shift: true }), - createKey('left', { ctrl: true }), - createKey('left', { meta: true }), - createKey('right'), + 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')], + positive: [createKey('right', { ctrl: true })], negative: [ - createKey('right', { shift: true }), - createKey('right', { ctrl: true }), - createKey('right', { meta: true }), - createKey('left'), + createKey('right'), + createKey('right', { shift: true, ctrl: true }), + createKey('right', { ctrl: true, meta: true }), + createKey('left', { ctrl: true }), ], }, From eade9fdc2f70af6aaf318cd2c72bf8efd888bb53 Mon Sep 17 00:00:00 2001 From: Qwen Code Bot <qwen-code-bot@users.noreply.github.com> Date: Fri, 24 Jul 2026 04:02:29 +0000 Subject: [PATCH 34/34] fix(core,cli): address review feedback on session refs (#7302) - Wrap MCP category label in t() for i18n consistency - Fix comment to match > 2 tab guard semantics - Add test for Ctrl+arrow with exactly 2 categories (guard boundary) - Fix budget loop to not reserve marker tokens when session fits --- .../src/ui/components/InputPrompt.test.tsx | 28 +++++++++++++++++++ .../cli/src/ui/components/InputPrompt.tsx | 6 ++-- .../src/ui/components/SuggestionsDisplay.tsx | 2 +- .../src/services/session-reference-service.ts | 11 ++++---- 4 files changed, 38 insertions(+), 9 deletions(-) diff --git a/packages/cli/src/ui/components/InputPrompt.test.tsx b/packages/cli/src/ui/components/InputPrompt.test.tsx index 1f4c8b1655c..809953844d5 100644 --- a/packages/cli/src/ui/components/InputPrompt.test.tsx +++ b/packages/cli/src/ui/components/InputPrompt.test.tsx @@ -2581,6 +2581,34 @@ describe('InputPrompt', () => { 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({ diff --git a/packages/cli/src/ui/components/InputPrompt.tsx b/packages/cli/src/ui/components/InputPrompt.tsx index d860683d82d..d94c965e457 100644 --- a/packages/cli/src/ui/components/InputPrompt.tsx +++ b/packages/cli/src/ui/components/InputPrompt.tsx @@ -1398,9 +1398,9 @@ 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 is - // more than one tab. Plain ←/→ are never consumed here, so they always - // move the caret in the editable buffer. + // 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); diff --git a/packages/cli/src/ui/components/SuggestionsDisplay.tsx b/packages/cli/src/ui/components/SuggestionsDisplay.tsx index 9b3f1ba4be6..f69ff5fb392 100644 --- a/packages/cli/src/ui/components/SuggestionsDisplay.tsx +++ b/packages/cli/src/ui/components/SuggestionsDisplay.tsx @@ -56,7 +56,7 @@ function categoryLabel(cat: SuggestionCategory | 'all'): string { case 'session': return t('Sessions'); case 'mcp': - return 'MCP'; + return t('MCP'); case 'extension': return t('Extensions'); default: diff --git a/packages/core/src/services/session-reference-service.ts b/packages/core/src/services/session-reference-service.ts index 997f0e1c9da..31998154729 100644 --- a/packages/core/src/services/session-reference-service.ts +++ b/packages/core/src/services/session-reference-service.ts @@ -76,14 +76,14 @@ export class SessionReferenceService { const title = opts.title ?? this.deriveTitle(records) ?? sessionId; const header = `--- Referenced session "${title}" (slimmed, read-only) ---`; - const overhead = this.estimate([header, '[earlier turns omitted]']); + 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 = overhead; + let total = headerCost; let start = lines.length; while (start > 0 && total + perLine[start - 1] <= budget) { total += perLine[start - 1]; @@ -96,6 +96,9 @@ export class SessionReferenceService { } 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'); @@ -110,9 +113,7 @@ export class SessionReferenceService { sessionId, title, messageCount: records.length, - approxTokens: - this.estimate(kept) + - (truncated ? overhead : this.estimate([header])), + approxTokens: this.estimate(kept) + overhead, }, truncated, };