docs/superpowers/plans/2026-07-17-at-session-reference.md
@ Session Reference + Tabbed Completion UI — Implementation PlanFor agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (
- [ ]) syntax for tracking.
Goal: Let a user reference a prior chat session via @, injecting a deterministically-slimmed copy of its history as read-only context, and redesign the @ completion dropdown into a tab-switched layout.
Architecture: Backend is a pure, unit-testable core service (SessionReferenceService) plus a ref parser (session-mention-ref); it loads a session via the existing SessionService, slims records to user/assistant text + one-line tool summaries, and tail-trims to a fixed token budget. atCommandProcessor gains a @session: routing branch that injects the slimmed block as a scoped-mention part. Frontend adds a category field to Suggestion, a session-suggestion producer in useAtCompletion, and a tab bar in SuggestionsDisplay driven by a new tab-switch keybinding.
Tech Stack: TypeScript, React + Ink (TUI), Vitest, existing qwen-code SessionService / atCommandProcessor / completion hooks.
SessionService.loadSession / listSessions, which already enforce sessionBelongsToCurrentProject. Never scan other projects' chat dirs.runSideQuery or ChatCompressionService.[tool: <name> — <status>]); never include tool result bodies.[earlier turns omitted], set truncated: true.QwenLM/qwen-code house rule).extension-mention-ref.ts for the ref parser and producer; mirror StatsDialog.tsx tab trio for the tab UI.feat:, test:, refactor:).session-mention-ref — parse/build/validate @session: refsFiles:
packages/cli/src/ui/hooks/session-mention-ref.tspackages/cli/src/ui/hooks/session-mention-ref.test.tsInterfaces:
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'.
// packages/cli/src/ui/hooks/session-mention-ref.test.ts
import { describe, it, expect } from 'vitest';
import {
parseSessionRef,
buildSessionRef,
isSessionId,
SESSION_MENTION_PREFIX,
} from './session-mention-ref.js';
const UUID = '3f2504e0-4f89-41d3-9a0c-0305e82c3301';
describe('sessionMentionRef', () => {
it('returns null for non-session tokens', () => {
expect(parseSessionRef('file.txt')).toBeNull();
expect(parseSessionRef('ext:foo')).toBeNull();
});
it('parses a UUID remainder as an id', () => {
expect(parseSessionRef(`${SESSION_MENTION_PREFIX}${UUID}`)).toEqual({
id: UUID,
});
});
it('parses a non-UUID remainder as a title', () => {
expect(parseSessionRef('session:Fix auth bug')).toEqual({
title: 'Fix auth bug',
});
});
it('treats an empty remainder as null (lone prefix)', () => {
expect(parseSessionRef('session:')).toBeNull();
});
it('builds a ref without a leading @', () => {
expect(buildSessionRef(UUID)).toBe(`session:${UUID}`);
});
it('recognizes UUIDs', () => {
expect(isSessionId(UUID)).toBe(true);
expect(isSessionId('not-a-uuid')).toBe(false);
});
});
Run: npx vitest run packages/cli/src/ui/hooks/session-mention-ref.test.ts
Expected: FAIL — Cannot find module './session-mention-ref.js'.
// packages/cli/src/ui/hooks/session-mention-ref.ts
export const SESSION_MENTION_PREFIX = 'session:';
const UUID_RE =
/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
export interface SessionRef {
id?: string;
title?: string;
}
export function isSessionId(value: string): boolean {
return UUID_RE.test(value.trim());
}
export function parseSessionRef(pathName: string): SessionRef | null {
if (!pathName.startsWith(SESSION_MENTION_PREFIX)) return null;
const remainder = pathName.slice(SESSION_MENTION_PREFIX.length).trim();
if (remainder.length === 0) return null;
return isSessionId(remainder) ? { id: remainder } : { title: remainder };
}
export function buildSessionRef(idOrTitle: string): string {
return `${SESSION_MENTION_PREFIX}${idOrTitle}`;
}
Run: npx vitest run packages/cli/src/ui/hooks/session-mention-ref.test.ts
Expected: PASS (6 tests).
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"
SessionReferenceService — load + slim + budget-trimFiles:
packages/core/src/services/session-reference-service.tspackages/core/src/services/session-reference-service.test.tspackages/core/src/index.ts (add export * from './services/session-reference-service.js'; alongside existing service exports)Interfaces:
SessionService.loadSession(id): Promise<ResumedSessionData | undefined> (existing); ResumedSessionData.conversation.messages: ChatRecord[]; ChatRecord fields type, message?: Content, toolCallResult?; estimateContentTokens(contents: Content[]): number from ./tokenEstimation.js.const SESSION_REF_TOKEN_BUDGET = 8000interface SlimmedSessionReference { text: string; meta: { sessionId: string; title: string; messageCount: number; approxTokens: number }; truncated: boolean }class SessionReferenceService { constructor(cwd: string); resolve(sessionId: string, opts?: { budgetTokens?: number; title?: string }): Promise<SlimmedSessionReference | { notFound: true }> }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.
Step 1: Write the failing test
// packages/core/src/services/session-reference-service.test.ts
import { describe, it, expect, vi } from 'vitest';
import { SessionReferenceService } from './session-reference-service.js';
import type { ResumedSessionData } from './sessionService.js';
function fakeResumed(messages: unknown[]): ResumedSessionData {
return {
conversation: {
sessionId: 's1',
projectHash: 'h',
startTime: '',
lastUpdated: '',
messages: messages as never,
},
filePath: '/tmp/s1.jsonl',
lastCompletedUuid: null,
} as ResumedSessionData;
}
function makeSvc(resumed: ResumedSessionData | undefined) {
const svc = new SessionReferenceService('/proj');
// Inject a stub SessionService.loadSession
(svc as unknown as { loadSession: () => Promise<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');
});
> **Implementation note:** `ToolCallResponseInfo` has no `displayName` field in production. The shipped `session-reference-service.ts` derives tool names from `functionResponse` parts instead.
it('tail-trims to budget and marks truncated', async () => {
const many = Array.from({ length: 50 }, (_, i) => ({
type: 'user',
message: {
role: 'user',
parts: [{ text: `turn ${i} ` + 'x'.repeat(400) }],
},
}));
const svc = makeSvc(fakeResumed(many));
const res = await svc.resolve('s1', { budgetTokens: 200 });
if ('notFound' in res) throw new Error('unexpected');
expect(res.truncated).toBe(true);
expect(res.text).toContain('[earlier turns omitted]');
expect(res.text).toContain('turn 49'); // newest retained
expect(res.text).not.toContain('turn 0'); // oldest dropped
});
});
Run: npx vitest run packages/core/src/services/session-reference-service.test.ts
Expected: FAIL — Cannot find module './session-reference-service.js'.
// packages/core/src/services/session-reference-service.ts
import type { Content, Part } from '@google/genai';
import { SessionService } from './sessionService.js';
import type { ChatRecord } from './chatRecordingService.js';
import { estimateContentTokens } from './tokenEstimation.js';
export const SESSION_REF_TOKEN_BUDGET = 8000;
export interface SlimmedSessionReference {
text: string;
meta: {
sessionId: string;
title: string;
messageCount: number;
approxTokens: number;
};
truncated: boolean;
}
export class SessionReferenceService {
private readonly sessionService: SessionService;
constructor(private readonly cwd: string) {
this.sessionService = new SessionService(cwd);
}
// Indirection kept as an instance method so tests can stub it.
protected loadSession(sessionId: string) {
return this.sessionService.loadSession(sessionId);
}
async resolve(
sessionId: string,
opts: { budgetTokens?: number } = {},
): Promise<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 = sessionId; // TODO: derive friendlier title from first user message
const text =
body.trim().length === 0
? `--- Referenced session "${title}" (slimmed, read-only) ---\n(no textual content)`
: `--- Referenced session "${title}" (slimmed, read-only) ---\n${body}`;
return {
text,
meta: {
sessionId,
title,
messageCount: records.length,
approxTokens: this.estimate(kept),
},
truncated,
};
}
private estimate(lines: string[]): number {
const contents: Content[] = [
{ role: 'user', parts: [{ text: lines.join('\n') }] },
];
return estimateContentTokens(contents);
}
private recordsToLines(records: ChatRecord[]): string[] {
const out: string[] = [];
for (const rec of records) {
if (rec.toolCallResult || this.hasFunctionPart(rec.message)) {
const name =
rec.toolCallResult?.displayName ??
this.functionName(rec.message) ??
'tool';
const status = rec.toolCallResult?.status ?? 'ok';
out.push(`[tool: ${name} — ${status}]`);
}
// Emit user/assistant text separately (before the tool summary block)
// to avoid dropping assistant reasoning on tool-calling turns.
if (rec.type === 'user') {
const text = this.visibleText(rec.message);
if (text) out.push(`User: ${text}`);
} else if (rec.type === 'assistant') {
const text = this.visibleText(rec.message);
if (text) out.push(`Assistant: ${text}`);
}
// system records ignored
}
return out;
}
// NOTE: The shipped implementation (session-reference-service.ts) uses a
// two-pass approach instead — emit visible text first (user/assistant),
// then tool summaries from functionResponse parts in a separate pass.
// The `continue` above would silently drop assistant reasoning on turns
// that also call a tool; do NOT copy this version verbatim.
private visibleText(message?: Content): string {
if (!message?.parts) return '';
return message.parts
.filter((p: Part) => !(p as { thought?: boolean }).thought && p.text)
.map((p: Part) => p.text)
.join('')
.trim();
}
private hasFunctionPart(message?: Content): boolean {
return (
message?.parts?.some(
(p: Part) =>
(p as { functionCall?: unknown }).functionCall ||
(p as { functionResponse?: unknown }).functionResponse,
) ?? false
);
}
> **Implementation note:** The shipped implementation matches only `functionResponse` parts (not `functionCall`), because call-side records produce duplicate always-'ok' summaries before the result arrives.
private functionName(message?: Content): string | undefined {
const p = message?.parts?.find(
(x: Part) =>
(x as { functionCall?: { name?: string } }).functionCall ||
(x as { functionResponse?: { name?: string } }).functionResponse,
);
return (
(p as { functionCall?: { name?: string } })?.functionCall?.name ??
(p as { functionResponse?: { name?: string } })?.functionResponse?.name
);
}
}
Run: npx vitest run packages/core/src/services/session-reference-service.test.ts
Expected: PASS (4 tests).
Add to packages/core/src/index.ts (near other ./services/* exports):
export * from './services/session-reference-service.js';
Run: npx tsc --noEmit -p packages/core/tsconfig.json
Expected: no errors in the new file.
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"
@session: through atCommandProcessor and injectFiles:
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)packages/cli/src/ui/hooks/atCommandProcessor.session.test.tsInterfaces:
parseSessionRef, SESSION_MENTION_PREFIX (Task 1); SessionReferenceService (Task 2); existing SessionService.findSessionsByTitle(title): Promise<SessionListItem[]>.{ text } part appended to the scoped-mention bucket; a "Referenced session" display card; literal-text fallback for unresolved refs.Behavior:
parseSessionRef(pathName) non-null: resolve the id.
{ id } → use directly.{ title } → findSessionsByTitle(title); 0 matches → not-found; >1 → ambiguous; 1 → use its sessionId.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.{ 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).resolve() (e.g. corrupt session file, I/O error) → leave the @session:… token as literal text and push a warning note; do NOT throw.// 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');
});
});
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.
In atCommandProcessor.ts, add imports at the top:
import { parseSessionRef } from './session-mention-ref.js';
import { SessionReferenceService } from '@qwen-code/qwen-code-core';
Add this branch immediately after the parseMcpServerRef handling (~line 281) and before the filesystem isPathWithinWorkspace check (~line 320):
const sessionRef = parseSessionRef(pathName);
if (sessionRef) {
let sessionId = sessionRef.id;
if (!sessionId && sessionRef.title) {
try {
const matches = await new SessionService(
config.getWorkingDir(),
).findSessionsByTitle(sessionRef.title);
if (matches.length === 1) {
sessionId = matches[0].sessionId;
} else {
// 0 or >1: leave literal, warn, skip injection
addItem(
{
type: MessageType.INFO,
text:
matches.length === 0
? `No session matches "@session:${sessionRef.title}".`
: `"@session:${sessionRef.title}" is ambiguous (${matches.length} matches); use the picker.`,
},
userMessageTimestamp,
);
continue; // token already retained as literal text
}
} catch {
// emit error card and continue
}
> **Implementation note:** The shipped code emits a proper error card (`addItem` with `MessageType.INFO`) inside this `catch` before `continue`, rather than falling through to `resolve(sessionId!)` with `sessionId` still `undefined`.
}
try {
const ref = await new SessionReferenceService(
config.getWorkingDir(),
).resolve(sessionId!);
if ('notFound' in ref) {
addItem(
{ type: MessageType.INFO, text: `Session "${sessionId}" not found.` },
userMessageTimestamp,
);
continue;
}
scopedMentionEntries.push({
part: { text: ref.text },
// mirror the card shape used by the extension/MCP-server branches:
card: { title: 'Referenced session', detail: ref.meta.title },
});
continue;
} catch {
addItem(
{
type: MessageType.INFO,
text: `Failed to load session "${sessionId}".`,
},
userMessageTimestamp,
);
continue;
}
}
(Implementer: match the exact scopedMentionEntries element shape and addItem/MessageType imports already used in this file; the block above shows intent and names.)
Run: npx vitest run packages/cli/src/ui/hooks/atCommandProcessor.session.test.ts
Expected: PASS.
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.
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"
category field + session-suggestion producerFiles:
packages/cli/src/ui/components/SuggestionsDisplay.tsx:19-45 (add category to Suggestion, add SuggestionCategory type)packages/cli/src/ui/hooks/session-completion.ts (producer, mirrors extension-mention-ref.ts)packages/cli/src/ui/hooks/useAtCompletion.ts (call producer; tag file results; merge near lines 439/486/493)packages/cli/src/ui/hooks/session-completion.test.tsInterfaces:
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?: SuggestionCategoryasync 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:
export type SuggestionCategory = 'file' | 'session' | 'mcp' | 'extension';
and inside Suggestion:
/** Grouping category for the tabbed completion UI. Defaults to 'file'. */
category?: SuggestionCategory;
Producer test:
// packages/cli/src/ui/hooks/session-completion.test.ts
import { describe, it, expect, vi } from 'vitest';
vi.mock('@qwen-code/qwen-code-core', async (orig) => {
const actual = (await orig()) as Record<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 './session-completion.js';
describe('getSessionSuggestions', () => {
it('maps sessions to category:session suggestions with @session: values', async () => {
const out = await getSessionSuggestions('/proj', '');
expect(out).toHaveLength(2);
expect(out[0]).toMatchObject({
label: 'Fix auth bug',
value: 'session:id-1',
category: 'session',
});
// falls back to first prompt when no custom title
expect(out[1].label).toBe('add tests');
});
it('filters by pattern against title and prompt', async () => {
const out = await getSessionSuggestions('/proj', 'auth');
expect(out).toHaveLength(1);
expect(out[0].value).toBe('session:id-1');
});
});
Run: npx vitest run packages/cli/src/ui/hooks/session-completion.test.ts
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,
SESSION_MENTION_PREFIX,
} from './session-mention-ref.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 stripped = pattern.startsWith(SESSION_MENTION_PREFIX)
? pattern.slice(SESSION_MENTION_PREFIX.length)
: pattern.toLowerCase() === 'session'
? ''
: pattern;
const needle = stripped.trim().toLowerCase();
return items
.map((s) => {
const label = s.customTitle?.trim() || s.prompt || s.sessionId;
return {
label,
value: buildSessionRef(s.sessionId),
description: s.customTitle ? s.prompt : undefined,
sourceBadge: 'Session',
category: 'session' as const,
} satisfies Suggestion;
})
.filter((sug) =>
needle.length === 0
? true
: `${sug.label} ${sug.description ?? ''}`
.toLowerCase()
.includes(needle),
);
}
Run: npx vitest run packages/cli/src/ui/hooks/session-completion.test.ts
Expected: PASS (2 tests).
Step 5: Wire into useAtCompletion.ts
Add import: import { getSessionSuggestions } from './session-completion.js';
Tag file results with category: 'file' at the fileSuggestions map (~line 486):
const fileSuggestions = results.map((p) => ({
label: p,
value: escapePath(p),
isDirectory: p.endsWith('/'),
category: 'file' as const,
}));
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).mcpSuggestions is assembled (~line 439) and merged with files (~line 493), add sessions so bare @ shows them: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.)
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.
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"
SuggestionsDisplayFiles:
packages/cli/src/ui/components/SuggestionsDisplay.tsx (add activeCategory prop, tab bar, row filtering)packages/cli/src/ui/components/SuggestionsDisplay.test.tsxInterfaces:
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
// 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');
});
});
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:
activeCategory?: SuggestionCategory | 'all';
availableCategories?: Array<SuggestionCategory | 'all'>;
const CATEGORY_LABEL: Record<SuggestionCategory | 'all', string> = {
all: 'All',
file: 'Files',
session: 'Sessions',
mcp: 'MCP',
extension: 'Extensions',
};
const visible =
!activeCategory || activeCategory === 'all'
? suggestions
: suggestions.filter((s) => (s.category ?? 'file') === activeCategory);
Use visible in place of suggestions for the existing scrollOffset/MAX_SUGGESTIONS_TO_SHOW slice.
Implementation note: The shipped implementation consolidates category filtering inside
useCompletion(filteringrawSuggestionsinto the exposedsuggestionsmemo) so thatactiveSuggestionIndexalways addresses the same visible list. The split described here between Tasks 5 and 6 would misalign the highlight index.
StatsTabs in StatsDialog.tsx) above the list, only when (availableCategories?.length ?? 0) > 2:{
(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>
);
}
Run: npx vitest run packages/cli/src/ui/components/SuggestionsDisplay.test.tsx
Expected: PASS (3 tests).
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"
activeCategory state + ←/→ tab-switch keybindingFiles:
packages/cli/src/ui/hooks/useCompletion.ts (add activeCategory, availableCategories, switchCategory(direction), reset index on switch)packages/cli/src/ui/keyMatchers.ts (or the keybindings command file) — add Command.COMPLETION_TAB_LEFT / COMPLETION_TAB_RIGHT bound to ←/→ while suggestions are shownpackages/cli/src/ui/components/InputPrompt.tsx (~lines 1386–1448) — handle the new commands; pass activeCategory/availableCategories into suggestionDisplayProps (~line 1948)packages/cli/src/ui/hooks/useCompletion.test.ts (extend existing)Interfaces:
Suggestion.category (Task 4); SuggestionCategory (Task 4).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'.
// 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.)
Run: npx vitest run packages/cli/src/ui/hooks/useCompletion.test.ts -t 'availableCategories'
Expected: FAIL — availableCategories/switchCategory undefined.
useCompletion.tsAdd:
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] : ['all'];
}, [suggestions]);
const [activeCategory, setActiveCategory] = useState<
SuggestionCategory | 'all'
>('all');
useEffect(() => {
if (!availableCategories.includes(activeCategory)) setActiveCategory('all');
}, [availableCategories, activeCategory]);
const switchCategory = useCallback(
(direction: 1 | -1) => {
setActiveCategory((cur) => {
const idx = availableCategories.indexOf(cur);
const next =
(idx + direction + availableCategories.length) %
availableCategories.length;
return availableCategories[next];
});
setActiveSuggestionIndex(0);
setVisibleStartIndex(0);
},
[availableCategories],
);
Implementation note: The shipped code adds
if (idx === -1) return 'all';before the modular arithmetic to guard against a stale category during React state batching.
Return activeCategory, availableCategories, switchCategory from the hook.
Step 4: Add keybindings + InputPrompt wiring
In the keybindings command enum/file add COMPLETION_TAB_LEFT (left/←) and COMPLETION_TAB_RIGHT (right/→).
In InputPrompt.tsx inside the showCompletionSuggestions block (~line 1386), BEFORE the ACCEPT_SUGGESTION handling:
if (keyMatchers[Command.COMPLETION_TAB_RIGHT](key)) {
completion.switchCategory(1);
return true;
}
if (keyMatchers[Command.COMPLETION_TAB_LEFT](key)) {
completion.switchCategory(-1);
return true;
}
suggestionDisplayProps (~line 1948) add: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.)
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.
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"
Files: none (verification only)
Run: npm run build && npx vitest run packages/core packages/cli
Expected: all pass (including the new suites).
Run: npm run lint && npm run typecheck
Expected: clean.
@ → confirm a tab bar appears with All / Files / Sessions.→ to reach Sessions, ↑/↓ to select one, Enter/Tab to accept → buffer shows @session:<id>.Referenced session card renders and the slimmed block reaches the model (labeled --- Referenced session … ---, no tool result bodies).@session:<garbage-uuid> and submit → confirm the "not found" info line and literal-text fallback (no crash).Edit docs/superpowers/specs/2026-07-17-at-session-reference-design.md: change Status: Proposed → Status: Implemented.
git add docs/superpowers/specs/2026-07-17-at-session-reference-design.md
git commit -m "docs: mark @ session reference design as implemented"
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.