From 1245157616c7a431399342796b75586fad29998a Mon Sep 17 00:00:00 2001 From: universe-hcy Date: Thu, 23 Jul 2026 02:29:22 -0700 Subject: [PATCH 1/5] =?UTF-8?q?feat:=20=E6=B7=BB=E5=8A=A0=20push/pop=20?= =?UTF-8?q?=E4=B8=8A=E4=B8=8B=E6=96=87=E6=A0=88=EF=BC=88=E8=AE=A8=E8=AE=BA?= =?UTF-8?q?=E5=88=86=E6=94=AF=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit /push 开一个继承完整上下文的讨论旁支,/pop 把讨论蒸馏成结构化 digest 回卷主线——讨论噪音不污染主线、只留结论。支持嵌套(≤3)、--to #N 跨层弹出、 --list 零成本列栈,以及栈感知 auto-compact 三选确认(压到最近/指定 push 点/全量)。 纯增量、opt-in,feature flag PUSH_POP。 Co-Authored-By: claude-opus-4-8[1m] --- scripts/defines.ts | 4 + src/Tool.ts | 20 + src/commands.ts | 12 + src/commands/__tests__/pushpop.test.ts | 297 ++++++++++ src/commands/pop/index.ts | 13 + src/commands/pop/pop.ts | 73 +++ src/commands/push/index.ts | 14 + src/commands/push/push.ts | 53 ++ .../pushStack/CompactStrategyDialog.tsx | 83 +++ src/main.tsx | 1 + src/screens/REPL.tsx | 514 ++++++++++++++---- src/services/compact/autoCompact.ts | 129 +++++ src/services/compact/compact.ts | 26 +- src/services/compact/prompt.ts | 34 +- src/services/pushStack/digestPrompt.ts | 30 + src/services/pushStack/state.ts | 67 +++ src/state/AppStateStore.ts | 4 + src/utils/forkedAgent.ts | 4 + 18 files changed, 1281 insertions(+), 97 deletions(-) create mode 100644 src/commands/__tests__/pushpop.test.ts create mode 100644 src/commands/pop/index.ts create mode 100644 src/commands/pop/pop.ts create mode 100644 src/commands/push/index.ts create mode 100644 src/commands/push/push.ts create mode 100644 src/components/pushStack/CompactStrategyDialog.tsx create mode 100644 src/services/pushStack/digestPrompt.ts create mode 100644 src/services/pushStack/state.ts diff --git a/scripts/defines.ts b/scripts/defines.ts index a21dfb3985..e39edd7754 100644 --- a/scripts/defines.ts +++ b/scripts/defines.ts @@ -98,4 +98,8 @@ export const DEFAULT_BUILD_FEATURES = [ // Persistent thread goal command — auto-continuation, JSONL persistence, // strict completion/blocked audit. See src/services/goal. 'GOAL', + // Push/Pop context stack — /push opens a discussion branch inheriting full + // context, /pop distills it to a digest and rewinds the mainline. Opt-in, + // zero code path when unused. See docs/features/push-pop-context-stack.md. + 'PUSH_POP', ] as const diff --git a/src/Tool.ts b/src/Tool.ts index caeac479e6..820344af09 100644 --- a/src/Tool.ts +++ b/src/Tool.ts @@ -10,6 +10,11 @@ import type { import type { UUID } from 'crypto' import type { z } from 'zod/v4' import type { Command } from './commands.js' +import type { + PopOptions, + CompactStrategyMarker, + CompactStrategyChoice, +} from './services/pushStack/state.js' import type { CanUseToolFn } from './hooks/useCanUseTool.js' import type { ThinkingConfig } from './utils/thinking.js' @@ -239,6 +244,21 @@ export type ToolUseContext = { onCompactProgress?: (event: CompactProgressEvent) => void setSDKStatus?: (status: SDKStatus) => void openMessageSelector?: () => void + /** Push/pop context stack (docs/features/push-pop-context-stack.md). Only + * wired in interactive REPL contexts; the heavy apply logic lives there. */ + pushContextMark?: (note: string) => void + applyPop?: (opts: PopOptions) => void + /** After a push-stack-aware auto-compact, retain only markers at or after + * the given marker id. Called by autoCompactIfNeeded to sync the REPL's + * pushStack when older markers are consumed by the compaction. */ + retainPushMarkersFrom?: (fromMarkerId: string) => void + /** Interactive three-way auto-compact strategy picker. When wired (REPL), + * the query loop awaits this before deciding full vs. partial compaction. + * Not wired in non-interactive / forked-agent contexts. */ + askCompactStrategy?: (opts: { + markers: ReadonlyArray + signal: AbortSignal + }) => Promise updateFileHistoryState: ( updater: (prev: FileHistoryState) => FileHistoryState, ) => void diff --git a/src/commands.ts b/src/commands.ts index bcfb6399f4..66365fd151 100644 --- a/src/commands.ts +++ b/src/commands.ts @@ -168,6 +168,16 @@ const goalCmd = feature('GOAL') require('./commands/goal/index.js') as typeof import('./commands/goal/index.js') ).default : null +const pushCmd = feature('PUSH_POP') + ? ( + require('./commands/push/index.js') as typeof import('./commands/push/index.js') + ).default + : null +const popCmd = feature('PUSH_POP') + ? ( + require('./commands/pop/index.js') as typeof import('./commands/pop/index.js') + ).default + : null /* eslint-enable @typescript-eslint/no-require-imports */ import thinkback from './commands/thinkback/index.js' import thinkbackPlay from './commands/thinkback-play/index.js' @@ -372,6 +382,8 @@ const COMMANDS = memoize((): Command[] => [ ...(buddy ? [buddy] : []), ...(poor ? [poor] : []), ...(goalCmd ? [goalCmd] : []), + ...(pushCmd ? [pushCmd] : []), + ...(popCmd ? [popCmd] : []), ...(proactive ? [proactive] : []), ...(monitorCmd ? [monitorCmd] : []), ...(coordinatorCmd ? [coordinatorCmd] : []), diff --git a/src/commands/__tests__/pushpop.test.ts b/src/commands/__tests__/pushpop.test.ts new file mode 100644 index 0000000000..022c06287d --- /dev/null +++ b/src/commands/__tests__/pushpop.test.ts @@ -0,0 +1,297 @@ +import { describe, expect, mock, test, beforeAll } from 'bun:test' +import { logMock } from '../../../tests/mocks/log.js' +import { debugMock } from '../../../tests/mocks/debug.js' + +// Mock bun:bundle before anything that calls feature() +mock.module('bun:bundle', () => ({ feature: (_name: string) => false })) + +// Cut bootstrap/state.ts side-effect chain +mock.module('src/utils/log.ts', logMock) +mock.module('src/utils/debug.ts', debugMock) +mock.module('src/utils/config.ts', () => ({ + getGlobalConfig: () => ({}), + saveGlobalConfig: async () => {}, + getGlobalConfigWriteCount: () => 0, +})) +mock.module('src/utils/settings/settings.js', () => ({ + getCachedOrLoadSettings: async () => ({}), + getCachedSettings: () => ({}), +})) + +let parsePopArgs: ( + args: string, +) => import('../../services/pushStack/state.js').PopOptions | string + +beforeAll(async () => { + const popModule = await import('../pop/pop.js') + parsePopArgs = popModule.parsePopArgs +}) + +describe('parsePopArgs', () => { + test('empty args yields empty options', () => { + const result = parsePopArgs('') + expect(typeof result).toBe('object') + expect(result).toEqual({}) + }) + + test('--discard sets discard flag', () => { + const result = parsePopArgs('--discard') + expect(result).toEqual({ discard: true }) + }) + + test('--keep-code sets keepCode flag', () => { + const result = parsePopArgs('--keep-code') + expect(result).toEqual({ keepCode: true }) + }) + + test('--to #N parses marker number', () => { + const result = parsePopArgs('--to #2') + expect(result).toEqual({ to: 2 }) + }) + + test('--to N (without #) parses marker number', () => { + const result = parsePopArgs('--to 3') + expect(result).toEqual({ to: 3 }) + }) + + test('--to=N= syntax works', () => { + const result = parsePopArgs('--to=1') + expect(result).toEqual({ to: 1 }) + }) + + test('combined flags work', () => { + const result = parsePopArgs('--to #1 --keep-code') + expect(result).toEqual({ to: 1, keepCode: true }) + }) + + test('--to missing number returns error string', () => { + const result = parsePopArgs('--to') + expect(typeof result).toBe('string') + expect(result as string).toMatch(/Missing marker/) + }) + + test('--to 0 returns error (must be >= 1)', () => { + const result = parsePopArgs('--to 0') + expect(typeof result).toBe('string') + }) + + test('--to #abc returns error', () => { + const result = parsePopArgs('--to #abc') + expect(typeof result).toBe('string') + expect(result as string).toMatch(/Invalid marker/) + }) + + test('unknown flag returns error string', () => { + const result = parsePopArgs('--unknown') + expect(typeof result).toBe('string') + expect(result as string).toMatch(/Unknown option/) + }) +}) + +describe('push stack state', () => { + test('MAX_PUSH_DEPTH is 3', async () => { + const { MAX_PUSH_DEPTH } = await import('../../services/pushStack/state.js') + expect(MAX_PUSH_DEPTH).toBe(3) + }) + + test('getPushStackMirror starts empty', async () => { + const { getPushStackMirror } = await import( + '../../services/pushStack/state.js' + ) + const mirror = getPushStackMirror() + expect(Array.isArray(mirror)).toBe(true) + // May have stale content if other tests ran first — just verify it's readable + expect(mirror).toBeDefined() + }) + + test('setPushStackMirror round-trips', async () => { + const { getPushStackMirror, setPushStackMirror } = await import( + '../../services/pushStack/state.js' + ) + const marker = { + id: 'test-id', + messageUuid: + '00000000-0000-0000-0000-000000000001' as `${string}-${string}-${string}-${string}-${string}`, + note: 'test note', + timestamp: Date.now(), + anchorPreview: 'preview text', + } + setPushStackMirror([marker]) + const stack = getPushStackMirror() + expect(stack.length).toBe(1) + expect(stack[0]!.id).toBe('test-id') + expect(stack[0]!.note).toBe('test note') + // Restore + setPushStackMirror([]) + }) +}) + +describe('DIGEST_PROMPT and DIGEST_TEMPLATE', () => { + test('DIGEST_PROMPT contains required four-section structure', async () => { + const { DIGEST_PROMPT } = await import( + '../../services/pushStack/digestPrompt.js' + ) + expect(DIGEST_PROMPT).toContain('[Decisions]') + expect(DIGEST_PROMPT).toContain('[Rejected]') + expect(DIGEST_PROMPT).toContain('[Open questions]') + expect(DIGEST_PROMPT).toContain('[Action items]') + expect(DIGEST_PROMPT).toContain('') + expect(DIGEST_PROMPT).toContain('') + }) + + test('DIGEST_TEMPLATE is a non-empty string', async () => { + const { DIGEST_TEMPLATE } = await import( + '../../services/pushStack/digestPrompt.js' + ) + expect(typeof DIGEST_TEMPLATE).toBe('string') + expect(DIGEST_TEMPLATE.length).toBeGreaterThan(0) + }) +}) + +describe('pop command integration', () => { + test('empty stack returns error text', async () => { + const { call } = await import('../pop/pop.js') + const ctx = { + getAppState: () => ({ pushStack: [] as unknown[] }), + applyPop: undefined, + } as unknown as import('../../Tool.js').ToolUseContext + const result = await call('', ctx) + expect(result.type).toBe('text') + expect((result as { type: string; value: string }).value).toMatch( + /No push point to pop/, + ) + }) + + test('stack overflow --to N returns error text', async () => { + const { call } = await import('../pop/pop.js') + const ctx = { + getAppState: () => ({ + pushStack: [ + { + id: 'a', + messageUuid: '00000000-0000-0000-0000-000000000001', + note: '', + timestamp: 0, + anchorPreview: '', + }, + ], + }), + applyPop: undefined, + } as unknown as import('../../Tool.js').ToolUseContext + const result = await call('--to #5', ctx) + expect(result.type).toBe('text') + expect((result as { type: string; value: string }).value).toMatch( + /No push point #5/, + ) + }) + + test('interactive session with no applyPop returns error text', async () => { + const { call } = await import('../pop/pop.js') + const ctx = { + getAppState: () => ({ + pushStack: [ + { + id: 'a', + messageUuid: '00000000-0000-0000-0000-000000000001', + note: '', + timestamp: 0, + anchorPreview: '', + }, + ], + }), + applyPop: undefined, + } as unknown as import('../../Tool.js').ToolUseContext + const result = await call('', ctx) + expect(result.type).toBe('text') + expect((result as { type: string; value: string }).value).toMatch( + /interactive session/, + ) + }) + + test('invalid args returns error text', async () => { + const { call } = await import('../pop/pop.js') + const ctx = { + getAppState: () => ({ pushStack: [] as unknown[] }), + } as unknown as import('../../Tool.js').ToolUseContext + const result = await call('--unknown-flag', ctx) + expect(result.type).toBe('text') + expect((result as { type: string; value: string }).value).toMatch( + /Unknown option/, + ) + }) +}) + +describe('push command integration', () => { + test('--list on empty stack returns empty message', async () => { + const { call } = await import('../push/push.js') + const ctx = { + getAppState: () => ({ pushStack: [] }), + pushContextMark: undefined, + } as unknown as import('../../Tool.js').ToolUseContext + const result = await call('--list', ctx) + expect(result.type).toBe('text') + expect((result as { type: string; value: string }).value).toMatch( + /No active push points/, + ) + }) + + test('--list with items renders stack', async () => { + const { call } = await import('../push/push.js') + const now = Date.now() + const ctx = { + getAppState: () => ({ + pushStack: [ + { + id: 'a', + messageUuid: '00000000-0000-0000-0000-000000000001', + note: 'discuss API', + timestamp: now - 60000, + anchorPreview: 'let me think...', + }, + { + id: 'b', + messageUuid: '00000000-0000-0000-0000-000000000002', + note: '', + timestamp: now - 30000, + anchorPreview: 'another point', + }, + ], + }), + pushContextMark: undefined, + } as unknown as import('../../Tool.js').ToolUseContext + const result = await call('--list', ctx) + expect(result.type).toBe('text') + const text = (result as { type: string; value: string }).value + expect(text).toContain('Push stack (2)') + expect(text).toContain('#1') + expect(text).toContain('#2') + expect(text).toContain('discuss API') + }) + + test('no pushContextMark returns not-interactive error', async () => { + const { call } = await import('../push/push.js') + const ctx = { + getAppState: () => ({ pushStack: [] }), + pushContextMark: undefined, + } as unknown as import('../../Tool.js').ToolUseContext + const result = await call('my note', ctx) + expect(result.type).toBe('text') + expect((result as { type: string; value: string }).value).toMatch( + /interactive session/, + ) + }) + + test('with pushContextMark invokes it and returns skip', async () => { + const { call } = await import('../push/push.js') + let capturedNote = '' + const ctx = { + getAppState: () => ({ pushStack: [] }), + pushContextMark: (note: string) => { + capturedNote = note + }, + } as unknown as import('../../Tool.js').ToolUseContext + const result = await call('test branch note', ctx) + expect(result.type).toBe('skip') + expect(capturedNote).toBe('test branch note') + }) +}) diff --git a/src/commands/pop/index.ts b/src/commands/pop/index.ts new file mode 100644 index 0000000000..9bbdc6971e --- /dev/null +++ b/src/commands/pop/index.ts @@ -0,0 +1,13 @@ +import type { Command } from '../../commands.js' + +const pop = { + description: + 'Close the current discussion branch: distill it into a digest and rewind to the push point', + name: 'pop', + argumentHint: '[--to #N] [--discard] [--keep-code]', + type: 'local', + supportsNonInteractive: false, + load: () => import('./pop.js'), +} satisfies Command + +export default pop diff --git a/src/commands/pop/pop.ts b/src/commands/pop/pop.ts new file mode 100644 index 0000000000..80d65c2311 --- /dev/null +++ b/src/commands/pop/pop.ts @@ -0,0 +1,73 @@ +import type { LocalCommandResult } from '../../commands.js' +import type { ToolUseContext } from '../../Tool.js' +import type { PopOptions } from '../../services/pushStack/state.js' + +/** + * Parses `/pop` flags into PopOptions. Recognized: + * --to #N | --to N cross-layer pop back to marker ordinal N (§4.7) + * --discard truncate without generating a digest + * --keep-code truncate conversation but skip file rollback + * Returns a string on parse error. + */ +export function parsePopArgs(args: string): PopOptions | string { + const opts: PopOptions = {} + const tokens = args.trim().split(/\s+/).filter(Boolean) + for (let i = 0; i < tokens.length; i++) { + const tok = tokens[i]! + if (tok === '--discard') { + opts.discard = true + } else if (tok === '--keep-code') { + opts.keepCode = true + } else if (tok === '--to') { + const next = tokens[++i] + if (next === undefined) + return 'Missing marker number after --to (e.g. /pop --to #1).' + const n = Number.parseInt(next.replace(/^#/, ''), 10) + if (!Number.isInteger(n) || n < 1) return `Invalid marker number: ${next}` + opts.to = n + } else if (tok.startsWith('--to=')) { + const n = Number.parseInt(tok.slice('--to='.length).replace(/^#/, ''), 10) + if (!Number.isInteger(n) || n < 1) return `Invalid marker number: ${tok}` + opts.to = n + } else { + return `Unknown option: ${tok}` + } + } + return opts +} + +export async function call( + args: string, + context: ToolUseContext, +): Promise { + const parsed = parsePopArgs(args) + if (typeof parsed === 'string') { + return { type: 'text', value: parsed } + } + + const stack = context.getAppState().pushStack + if (stack.length === 0) { + return { + type: 'text', + value: + 'No push point to pop. Use /push [note] to open a discussion branch first.', + } + } + + if (parsed.to !== undefined && parsed.to > stack.length) { + return { + type: 'text', + value: `No push point #${parsed.to} — the stack has ${stack.length}.`, + } + } + + if (!context.applyPop) { + return { + type: 'text', + value: '/pop is only available in an interactive session.', + } + } + + context.applyPop(parsed) + return { type: 'skip' } +} diff --git a/src/commands/push/index.ts b/src/commands/push/index.ts new file mode 100644 index 0000000000..03765afa36 --- /dev/null +++ b/src/commands/push/index.ts @@ -0,0 +1,14 @@ +import type { Command } from '../../commands.js' + +const push = { + description: + 'Open a discussion branch that inherits the current context (use /pop to distill it back)', + name: 'push', + aliases: ['stack'], + argumentHint: '[note] | --list', + type: 'local', + supportsNonInteractive: false, + load: () => import('./push.js'), +} satisfies Command + +export default push diff --git a/src/commands/push/push.ts b/src/commands/push/push.ts new file mode 100644 index 0000000000..e6b1cfbd0d --- /dev/null +++ b/src/commands/push/push.ts @@ -0,0 +1,53 @@ +import type { LocalCommandResult } from '../../commands.js' +import type { ToolUseContext } from '../../Tool.js' +import type { PushMarker } from '../../services/pushStack/state.js' +import { formatRelativeTimeAgo } from '../../utils/format.js' + +/** + * Renders `/push --list`: the current push stack, newest last, numbered #1..#N. + * Pure local read of AppState.pushStack — zero API tokens, no fork (§4.7). + */ +function renderStackList(stack: readonly PushMarker[]): string { + if (stack.length === 0) { + return 'No active push points. Use /push [note] to open a discussion branch.' + } + const now = new Date() + const lines = stack.map((m, i) => { + const depth = i + 1 + const when = formatRelativeTimeAgo(new Date(m.timestamp), { + style: 'short', + now, + }) + // branchPreview (what the branch is about) wins over anchorPreview + // (where it forked from); both are truncated original conversation text. + const preview = m.branchPreview || m.anchorPreview || '' + const note = m.note ? ` · ${m.note}` : '' + const previewPart = preview ? ` · ${preview}` : '' + return `#${depth}${note}${previewPart} · ${when} · depth ${depth}` + }) + return `Push stack (${stack.length}):\n${lines.join('\n')}` +} + +export async function call( + args: string, + context: ToolUseContext, +): Promise { + const trimmed = args.trim() + + // `/push --list` (or `/stack --list`) lists the stack. Note: a bare + // `/push list` would be swallowed as a note, so a flag is required (§4.7). + if (trimmed === '--list' || trimmed === '-l') { + const stack = context.getAppState().pushStack + return { type: 'text', value: renderStackList(stack) } + } + + if (!context.pushContextMark) { + return { + type: 'text', + value: '/push is only available in an interactive session.', + } + } + + context.pushContextMark(trimmed) + return { type: 'skip' } +} diff --git a/src/components/pushStack/CompactStrategyDialog.tsx b/src/components/pushStack/CompactStrategyDialog.tsx new file mode 100644 index 0000000000..09910fa740 --- /dev/null +++ b/src/components/pushStack/CompactStrategyDialog.tsx @@ -0,0 +1,83 @@ +import * as React from 'react'; +import { Box, Text } from '@anthropic/ink'; +import { Select } from '../CustomSelect/select.js'; +import { Dialog } from '../design-system/Dialog.js'; +import type { CompactStrategyChoice, CompactStrategyMarker } from '../../services/pushStack/state.js'; + +const FULL_VALUE = '__full__'; + +interface CompactStrategyDialogProps { + /** Markers ordered oldest → newest; the last is the nearest (top) push point. */ + markers: ReadonlyArray; + onChoice: (choice: CompactStrategyChoice) => void; +} + +/** + * Stack-aware auto-compact picker (§4.2). Shown when auto-compact fires with a + * non-empty push stack. Cancelling defaults to the nearest push point — the + * safe choice that preserves the branch the user is currently working in. + */ +export function CompactStrategyDialog({ markers, onChoice }: CompactStrategyDialogProps): React.ReactNode { + const top = markers[markers.length - 1]; + const depth = markers.length; + + const handleChange = React.useCallback( + (value: string) => { + if (value === FULL_VALUE) { + onChoice({ kind: 'full' }); + } else { + onChoice({ kind: 'partial', markerId: value }); + } + }, + [onChoice], + ); + + // Cancel → nearest push point (preserve current branch, release most space). + const handleCancel = React.useCallback(() => { + if (top) onChoice({ kind: 'partial', markerId: top.id }); + else onChoice({ kind: 'full' }); + }, [onChoice, top]); + + // Nearest first (default highlight), then older markers, then full compact. + const options: { label: string; value: string; description?: string }[] = []; + if (top) { + const noteSuffix = top.note ? ` (${top.note})` : ''; + options.push({ + label: `Compress to nearest push point #${depth}${noteSuffix}`, + value: top.id, + description: + depth > 1 + ? `Keeps your current branch. Removes ${depth - 1} older push point(s).` + : 'Keeps your current branch; only the mainline before it is compacted.', + }); + } + // Older markers (#1..#depth-1), newest-older first. + for (let i = markers.length - 2; i >= 0; i--) { + const m = markers[i]!; + const noteSuffix = m.note ? ` (${m.note})` : ''; + options.push({ + label: `Compress to push point #${m.depth}${noteSuffix}`, + value: m.id, + description: `Keeps push points #${m.depth}..#${depth}; compacts everything before #${m.depth}.`, + }); + } + options.push({ + label: 'Full compact', + value: FULL_VALUE, + description: `Removes all ${depth} push point(s); /pop will no longer work for them.`, + }); + + return ( + + + + You have {depth} open discussion branch{depth > 1 ? 'es' : ''}. Compacting to a push point keeps that branch + intact. + +