From 2204b4d47ed244462b0f856e1e359875b03636e6 Mon Sep 17 00:00:00 2001 From: minorcell Date: Mon, 22 Dec 2025 18:39:38 +0800 Subject: [PATCH 1/4] feat: implement input history management and suggestion system - Add InputHistoryStore for managing and persisting input history entries. - Introduce types for input history and file suggestions. - Create a suggestion list component for displaying file and history suggestions. - Enhance InputPrompt to support history selection and file suggestions. - Implement slash commands for clearing the screen, exiting the session, and viewing history. - Update App component to handle loading historical turns from session files. - Refactor command handling to integrate new history and suggestion features. --- CLAUDE.md | 2 +- README.md | 4 +- bun.lock | 3 + packages/core/package.json | 1 + packages/core/src/config/config.test.ts | 22 +- packages/core/src/config/config.ts | 68 ++- packages/core/src/index.ts | 1 + packages/core/src/runtime/defaults.ts | 40 +- packages/core/src/runtime/prompt.md | 1 + packages/core/src/runtime/session.ts | 118 +++-- .../runtime/suggestions/file_suggestions.ts | 253 +++++++++ .../src/runtime/suggestions/history_store.ts | 108 ++++ .../core/src/runtime/suggestions/index.ts | 3 + .../core/src/runtime/suggestions/types.ts | 49 ++ packages/core/src/types.ts | 11 +- packages/ui/src/index.tsx | 3 + packages/ui/src/tui/App.tsx | 118 ++++- packages/ui/src/tui/commands.ts | 27 +- .../tui/components/input/SuggestionList.tsx | 62 +++ .../src/tui/components/layout/InputPrompt.tsx | 484 +++++++++++++++++- packages/ui/src/tui/slash/clear.ts | 11 + packages/ui/src/tui/slash/exit.ts | 10 + packages/ui/src/tui/slash/history.ts | 10 + packages/ui/src/tui/slash/index.ts | 8 + packages/ui/src/tui/slash/types.ts | 18 + 25 files changed, 1328 insertions(+), 107 deletions(-) create mode 100644 packages/core/src/runtime/suggestions/file_suggestions.ts create mode 100644 packages/core/src/runtime/suggestions/history_store.ts create mode 100644 packages/core/src/runtime/suggestions/index.ts create mode 100644 packages/core/src/runtime/suggestions/types.ts create mode 100644 packages/ui/src/tui/components/input/SuggestionList.tsx create mode 100644 packages/ui/src/tui/slash/clear.ts create mode 100644 packages/ui/src/tui/slash/exit.ts create mode 100644 packages/ui/src/tui/slash/history.ts create mode 100644 packages/ui/src/tui/slash/index.ts create mode 100644 packages/ui/src/tui/slash/types.ts diff --git a/CLAUDE.md b/CLAUDE.md index f31ad27..dc58e28 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -55,7 +55,7 @@ current_provider = "deepseek" max_steps = 100 stream_output = true -[[providers]] +[[providers.deepseek]] name = "deepseek" env_api_key = "DEEPSEEK_API_KEY" # 仅存环境变量名 model = "deepseek-chat" diff --git a/README.md b/README.md index 00cfd45..e1639f2 100644 --- a/README.md +++ b/README.md @@ -175,13 +175,15 @@ current_provider = "deepseek" max_steps = 100 stream_output = false -[[providers]] +[[providers.deepseek]] name = "deepseek" env_api_key = "DEEPSEEK_API_KEY" model = "deepseek-chat" base_url = "https://api.deepseek.com" ``` +可通过多个 `[[providers.]]` 段落配置多个 Provider。 + MCP 服务器示例: ```toml diff --git a/bun.lock b/bun.lock index def0978..0e9616e 100644 --- a/bun.lock +++ b/bun.lock @@ -21,6 +21,7 @@ "packages/core": { "name": "@memo/core", "dependencies": { + "ignore": "^7.0.5", "zod": "^4.1.13", }, }, @@ -182,6 +183,8 @@ "iconv-lite": ["iconv-lite@0.7.0", "https://registry.npmmirror.com/iconv-lite/-/iconv-lite-0.7.0.tgz", { "dependencies": { "safer-buffer": ">= 2.1.2 < 3.0.0" } }, "sha512-cf6L2Ds3h57VVmkZe+Pn+5APsT7FpqJtEhhieDCvrE2MK5Qk9MyffgQyuxQTm6BChfeZNtcOLHp9IcWRVcIcBQ=="], + "ignore": ["ignore@7.0.5", "https://registry.npmmirror.com/ignore/-/ignore-7.0.5.tgz", {}, "sha512-Hs59xBNfUIunMFgWAbGX5cq6893IbWg4KnrjbYwX3tx0ztorVgTDA6B2sxf8ejHJ4wz8BqGUMYlnzNBer5NvGg=="], + "indent-string": ["indent-string@5.0.0", "https://registry.npmmirror.com/indent-string/-/indent-string-5.0.0.tgz", {}, "sha512-m6FAo/spmsW2Ab2fU35JTYwtOKa2yAwXSwgjSv1TJzh4Mh7mC3lzAOVLBprb72XsTrgkEIsl7YrFNAiDiRhIGg=="], "inherits": ["inherits@2.0.4", "https://registry.npmmirror.com/inherits/-/inherits-2.0.4.tgz", {}, "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ=="], diff --git a/packages/core/package.json b/packages/core/package.json index 01bc0bc..bca5c69 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -4,6 +4,7 @@ "module": "src/index.ts", "private": true, "dependencies": { + "ignore": "^7.0.5", "zod": "^4.1.13" } } diff --git a/packages/core/src/config/config.test.ts b/packages/core/src/config/config.test.ts index 1928d72..cda77bf 100644 --- a/packages/core/src/config/config.test.ts +++ b/packages/core/src/config/config.test.ts @@ -122,6 +122,7 @@ describe('mcp config serialization', () => { }) const text = await Bun.file(configPath).text() + expect(text).toContain('[[providers.deepseek]]') expect(text).toContain('[mcp_servers.remote]') expect(text).toContain('type = "streamable_http"') expect(text).toContain('url = "https://example.com/mcp"') @@ -141,7 +142,7 @@ current_provider = "deepseek" stream_output = false max_steps = 42 -[[providers]] +[[providers.deepseek]] name = "deepseek" env_api_key = "DEEPSEEK_API_KEY" model = "deepseek-chat" @@ -171,4 +172,23 @@ url = "https://legacy.example.com/mcp" expectSseServer(legacy) expect(legacy.url).toBe('https://legacy.example.com/mcp') }) + + test('loadMemoConfig ignores legacy providers array', async () => { + const home = join(tempBase, 'memo-home-legacy') + process.env.MEMO_HOME = home + await mkdir(home, { recursive: true }) + const configText = ` +current_provider = "legacy" + +[[providers]] +name = "legacy" +env_api_key = "LEGACY_API_KEY" +model = "legacy-model" +` + await Bun.write(join(home, 'config.toml'), configText) + + const loaded = await loadMemoConfig() + expect(loaded.needsSetup).toBe(true) + expect(loaded.config.current_provider).not.toBe('legacy') + }) }) diff --git a/packages/core/src/config/config.ts b/packages/core/src/config/config.ts index 7484aa4..1bb328e 100644 --- a/packages/core/src/config/config.ts +++ b/packages/core/src/config/config.ts @@ -45,6 +45,8 @@ export type MemoConfig = { providers: ProviderConfig[] } +type ParsedMemoConfig = Omit, 'providers'> & { providers?: unknown } + const DEFAULT_MEMO_HOME = join(homedir(), '.memo') const DEFAULT_SESSIONS_DIR = 'sessions' const DEFAULT_MEMORY_FILE = 'memo.md' @@ -64,6 +66,29 @@ const DEFAULT_CONFIG: MemoConfig = { mcp_servers: {}, } +function formatTomlKey(key: string) { + return /^[A-Za-z0-9_-]+$/.test(key) ? key : JSON.stringify(key) +} + +function normalizeProviders(input: unknown): ProviderConfig[] { + if (!input || typeof input !== 'object' || Array.isArray(input)) return [] + + const providers: ProviderConfig[] = [] + for (const [key, value] of Object.entries(input as Record)) { + if (!value) continue + const entries = Array.isArray(value) ? value : [value] + for (const entry of entries) { + if (!entry || typeof entry !== 'object') continue + const provider = { ...(entry as ProviderConfig) } + if ((typeof provider.name !== 'string' || provider.name.length === 0) && key) { + provider.name = key + } + providers.push(provider) + } + } + return providers +} + function expandHome(path: string) { if (path.startsWith('~')) { return join(homedir(), path.slice(1)) @@ -74,13 +99,23 @@ function expandHome(path: string) { function serializeConfig(config: MemoConfig) { const providers = config.providers .map( - (p) => - `[[providers]] -name = "${p.name}" -env_api_key = "${p.env_api_key}" -model = "${p.model}" -${p.base_url ? `base_url = "${p.base_url}"\n` : ''}`, + (p) => { + const name = typeof p?.name === 'string' ? p.name : '' + if (!name) return '' + const key = formatTomlKey(name) + const lines = [ + `[[providers.${key}]]`, + `name = ${JSON.stringify(name)}`, + `env_api_key = ${JSON.stringify(String(p.env_api_key ?? ''))}`, + `model = ${JSON.stringify(String(p.model ?? ''))}`, + ] + if (p.base_url) { + lines.push(`base_url = ${JSON.stringify(String(p.base_url))}`) + } + return lines.join('\n') + }, ) + .filter(Boolean) .join('\n\n') let mcpSection = '' @@ -139,12 +174,13 @@ export async function loadMemoConfig(): Promise { return { config: DEFAULT_CONFIG, home, configPath, needsSetup: true } } const text = await file.text() - const parsed = parse(text) as Partial + const parsed = parse(text) as ParsedMemoConfig + const providers = normalizeProviders(parsed.providers) const merged: MemoConfig = { current_provider: parsed.current_provider ?? DEFAULT_CONFIG.current_provider, max_steps: parsed.max_steps ?? DEFAULT_CONFIG.max_steps, stream_output: parsed.stream_output ?? DEFAULT_CONFIG.stream_output, - providers: parsed.providers ?? [], + providers, mcp_servers: parsed.mcp_servers ?? {}, } const needsSetup = !merged.providers.length @@ -198,14 +234,22 @@ export function buildSessionPath(baseDir: string, sessionId: string) { const HH = String(now.getHours()).padStart(2, '0') const MM = String(now.getMinutes()).padStart(2, '0') const SS = String(now.getSeconds()).padStart(2, '0') - const cwd = process.cwd() - const safeParts = cwd.split(/[/\\]+/).map((p) => sanitizePathComponent(p)) - const truncatedParts = truncatePath(safeParts, 180) - const dirName = truncatedParts.join('-') + const dirName = buildSessionDirName(process.cwd()) const fileName = `${yyyy}-${mm}-${dd}_${HH}${MM}${SS}_${sessionId}.jsonl` return join(baseDir, dirName, fileName) } +function buildSessionDirName(cwd: string) { + const safeParts = cwd.split(/[/\\]+/).map((p) => sanitizePathComponent(p)) + const truncatedParts = truncatePath(safeParts, 180) + return truncatedParts.join('-') || 'root' +} + +/** 获取某个 cwd 对应的 session 日志目录。 */ +export function getSessionLogDir(baseDir: string, cwd: string) { + return join(baseDir, buildSessionDirName(cwd)) +} + /** 提供一个新的 sessionId,便于外部复用。 */ export function createSessionId() { return randomUUID() diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 8840884..9249f3a 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -7,3 +7,4 @@ export * from '@memo/core/config/config' export * from '@memo/core/utils/utils' export * from '@memo/core/utils/tokenizer' export * from '@memo/core/runtime/session' +export * from '@memo/core/runtime/suggestions' diff --git a/packages/core/src/runtime/defaults.ts b/packages/core/src/runtime/defaults.ts index 429815c..a63bb60 100644 --- a/packages/core/src/runtime/defaults.ts +++ b/packages/core/src/runtime/defaults.ts @@ -37,6 +37,7 @@ export async function withDefaultDeps( tokenCounter: TokenCounter maxSteps: number dispose: () => Promise + historyFilePath?: string }> { const loaded = await loadMemoConfig() const config = loaded.config @@ -83,6 +84,10 @@ export async function withDefaultDeps( return basePrompt } const streamOutput = options.stream ?? config.stream_output ?? false + const sessionsDir = getSessionsDir(loaded, options) + const historyFilePath = buildSessionPath(sessionsDir, sessionId) + const defaultHistorySink = new JsonlHistorySink(historyFilePath) + return { tools: combinedTools, dispose: async () => { @@ -91,7 +96,7 @@ export async function withDefaultDeps( }, callLLM: deps.callLLM ?? - (async (messages, onChunk) => { + (async (messages, onChunk, callOptions) => { const provider = selectProvider(config, options.providerName) const apiKey = process.env[provider.env_api_key] ?? @@ -107,12 +112,15 @@ export async function withDefaultDeps( baseURL: provider.base_url, }) if (streamOutput) { - const stream = await client.chat.completions.create({ - model: provider.model, - messages, - temperature: 0.35, - stream: true, - }) + const stream = await client.chat.completions.create( + { + model: provider.model, + messages, + temperature: 0.35, + stream: true, + }, + { signal: callOptions?.signal }, + ) let content = '' for await (const part of stream) { const delta = part.choices?.[0]?.delta?.content @@ -123,11 +131,14 @@ export async function withDefaultDeps( } return { content, streamed: true } } else { - const data = await client.chat.completions.create({ - model: provider.model, - messages, - temperature: 0.35, - }) + const data = await client.chat.completions.create( + { + model: provider.model, + messages, + temperature: 0.35, + }, + { signal: callOptions?.signal }, + ) const content = data.choices?.[0]?.message?.content if (typeof content !== 'string') { throw new Error('OpenAI-compatible API returned empty content') @@ -143,10 +154,9 @@ export async function withDefaultDeps( } }), loadPrompt, - historySinks: deps.historySinks ?? [ - new JsonlHistorySink(buildSessionPath(getSessionsDir(loaded, options), sessionId)), - ], + historySinks: deps.historySinks ?? [defaultHistorySink], tokenCounter: deps.tokenCounter ?? createTokenCounter(options.tokenizerModel), maxSteps: options.maxSteps ?? config.max_steps ?? 100, + historyFilePath: historyFilePath, } } diff --git a/packages/core/src/runtime/prompt.md b/packages/core/src/runtime/prompt.md index fc81b64..2e15243 100644 --- a/packages/core/src/runtime/prompt.md +++ b/packages/core/src/runtime/prompt.md @@ -32,6 +32,7 @@ 1. 一次回复依然建议只调用**一个**工具,然后等待结果。 2. 调用工具时,回复中只能包含 JSON 工具块,不能包含其它文本。 3. 只要您**不**输出 JSON 工具块,您的所有文本都会直接展示给用户作为最终回答。 +4. 如果需要先解释自己的思路,请单独发送一条自然语言消息;**当且仅当**已经确定要调用工具时,再单独回复 JSON 代码块。思考、状态提示与 JSON 工具调用绝不能出现在同一条消息中。 # 行为准则与设定 (Guidelines) diff --git a/packages/core/src/runtime/session.ts b/packages/core/src/runtime/session.ts index 962d74a..5f51d42 100644 --- a/packages/core/src/runtime/session.ts +++ b/packages/core/src/runtime/session.ts @@ -98,11 +98,16 @@ function parseToolInput(tool: ToolRegistry[string], rawInput: unknown) { return { ok: true as const, data: parsed.data } } +function isAbortError(err: unknown): err is Error { + return err instanceof Error && err.name === 'AbortError' +} + /** 进程内的对话 Session,实现多轮运行与日志写入。 */ class AgentSessionImpl implements AgentSession { public id: string public mode: SessionMode public history: ChatMessage[] + public historyFilePath?: string private turnIndex = 0 private tokenCounter: TokenCounter @@ -112,6 +117,8 @@ class AgentSessionImpl implements AgentSession { private maxSteps: number private hooks: HookRunnerMap private closed = false + private currentAbortController: AbortController | null = null + private cancelling = false constructor( private deps: AgentSessionDeps & { @@ -122,6 +129,7 @@ class AgentSessionImpl implements AgentSession { systemPrompt: string, tokenCounter: TokenCounter, maxSteps: number, + historyFilePath?: string, ) { this.id = options.sessionId || randomUUID() this.mode = options.mode || DEFAULT_SESSION_MODE @@ -130,6 +138,7 @@ class AgentSessionImpl implements AgentSession { this.sinks = deps.historySinks ?? [] this.maxSteps = maxSteps this.hooks = buildHookRunners(deps) + this.historyFilePath = historyFilePath } /** 写入 Session 启动事件,记录配置与 token 限制。 */ @@ -146,6 +155,9 @@ class AgentSessionImpl implements AgentSession { /** 执行一次 Turn:接受用户输入,走 ReAct 循环,返回最终结果与步骤轨迹。 */ async runTurn(input: string): Promise { + const abortController = new AbortController() + this.currentAbortController = abortController + this.cancelling = false this.turnIndex += 1 const turn = this.turnIndex const steps: AgentStepTrace[] = [] @@ -155,25 +167,26 @@ class AgentSessionImpl implements AgentSession { // 写入用户消息 this.history.push({ role: 'user', content: input }) - const promptTokens = this.tokenCounter.countMessages(this.history) - await this.emitEvent('turn_start', { - turn, - content: input, - meta: { tokens: { prompt: promptTokens } }, - }) - await runHook(this.hooks, 'onTurnStart', { - sessionId: this.id, - turn, - input, - history: snapshotHistory(this.history), - }) + try { + const promptTokens = this.tokenCounter.countMessages(this.history) + await this.emitEvent('turn_start', { + turn, + content: input, + meta: { tokens: { prompt: promptTokens } }, + }) + await runHook(this.hooks, 'onTurnStart', { + sessionId: this.id, + turn, + input, + history: snapshotHistory(this.history), + }) - let finalText = '' - let status: TurnStatus = 'ok' - let errorMessage: string | undefined + let finalText = '' + let status: TurnStatus = 'ok' + let errorMessage: string | undefined - // ReAct 主循环,受 MAX_STEPS 保护。 - for (let step = 0; step < this.maxSteps; step++) { + // ReAct 主循环,受 MAX_STEPS 保护。 + for (let step = 0; step < this.maxSteps; step++) { const estimatedPrompt = this.tokenCounter.countMessages(this.history) if (this.options.maxPromptTokens && estimatedPrompt > this.options.maxPromptTokens) { const limitMessage = `Context tokens (${estimatedPrompt}) exceed the limit. Please shorten the input or restart the session.` @@ -209,14 +222,39 @@ class AgentSessionImpl implements AgentSession { let usageFromLLM: Partial | undefined let streamed = false try { - const llmResult = await this.deps.callLLM(this.history, (chunk) => - this.deps.onAssistantStep?.(chunk, step), + const llmResult = await this.deps.callLLM( + this.history, + (chunk) => this.deps.onAssistantStep?.(chunk, step), + { signal: abortController.signal }, ) const normalized = normalizeLLMResponse(llmResult) assistantText = normalized.content usageFromLLM = normalized.usage streamed = Boolean(normalized.streamed) } catch (err) { + if (this.cancelling && isAbortError(err)) { + status = 'cancelled' + finalText = '' + errorMessage = 'Turn cancelled' + await this.emitEvent('final', { + turn, + step, + content: '', + role: 'assistant', + meta: { cancelled: true }, + }) + await runHook(this.hooks, 'onFinal', { + sessionId: this.id, + turn, + step, + finalText, + status, + errorMessage, + turnUsage: { ...turnUsage }, + steps, + }) + break + } const msg = `LLM call failed: ${(err as Error).message}` const finalPayload = JSON.stringify({ final: msg }) this.history.push({ role: 'assistant', content: finalPayload }) @@ -361,7 +399,7 @@ class AgentSessionImpl implements AgentSession { break } - if (!finalText) { + if (!finalText && status !== 'cancelled') { if (status === 'ok') { status = steps.length >= this.maxSteps ? 'max_steps' : 'error' } @@ -385,22 +423,33 @@ class AgentSessionImpl implements AgentSession { }) } - await this.emitEvent('turn_end', { - turn, - meta: { + await this.emitEvent('turn_end', { + turn, + meta: { + status, + stepCount: steps.length, + durationMs: Date.now() - turnStartedAt, + tokens: turnUsage, + }, + }) + + return { + finalText, + steps, status, - stepCount: steps.length, - durationMs: Date.now() - turnStartedAt, - tokens: turnUsage, - }, - }) + errorMessage, + tokenUsage: turnUsage, + } + } finally { + this.currentAbortController = null + this.cancelling = false + } + } - return { - finalText, - steps, - status, - errorMessage, - tokenUsage: turnUsage, + cancelCurrentTurn() { + if (this.currentAbortController) { + this.cancelling = true + this.currentAbortController.abort() } } @@ -463,6 +512,7 @@ export async function createAgentSession( systemPrompt, resolved.tokenCounter, resolved.maxSteps, + resolved.historyFilePath, ) await session.init() return session diff --git a/packages/core/src/runtime/suggestions/file_suggestions.ts b/packages/core/src/runtime/suggestions/file_suggestions.ts new file mode 100644 index 0000000..df041e9 --- /dev/null +++ b/packages/core/src/runtime/suggestions/file_suggestions.ts @@ -0,0 +1,253 @@ +/** @file 工作目录文件建议:递归扫描并根据输入前缀/片段排序。 */ +import { readFile, readdir } from 'node:fs/promises' +import { join, relative, sep } from 'node:path' +import ignore, { type Ignore } from 'ignore' +import type { FileSuggestion, FileSuggestionRequest } from './types' + +const DEFAULT_MAX_DEPTH = 6 +const DEFAULT_MAX_ENTRIES = 2500 +const DEFAULT_LIMIT = 25 +const DEFAULT_IGNORE = [ + '.git', + '.svn', + '.hg', + 'node_modules', + 'dist', + 'build', + '.next', + '.turbo', + '.cache', + '.output', + 'coverage', + 'tmp', + 'temp', + 'logs', + '*.log', +] + +type IndexedEntry = { + path: string + pathLower: string + segments: string[] + segmentsLower: string[] + depth: number + isDir: boolean +} + +type DirectoryCache = { + entries: IndexedEntry[] + signature: string + pending?: Promise +} + +const directoryCache = new Map() + +export function normalizePath(input: string) { + return input.split(sep).join('/') +} + +function buildSignature(opts: NormalizedOptions, gitignoreContent: string) { + return JSON.stringify({ + maxDepth: opts.maxDepth, + maxEntries: opts.maxEntries, + respectGitIgnore: opts.respectGitIgnore, + ignoreGlobs: opts.ignoreGlobs, + gitignore: gitignoreContent, + }) +} + +type NormalizedOptions = { + maxDepth: number + maxEntries: number + limit: number + respectGitIgnore: boolean + ignoreGlobs: string[] +} + +function normalizeOptions(req: FileSuggestionRequest): NormalizedOptions { + return { + maxDepth: typeof req.maxDepth === 'number' ? Math.max(1, req.maxDepth) : DEFAULT_MAX_DEPTH, + maxEntries: + typeof req.maxEntries === 'number' ? Math.max(100, req.maxEntries) : DEFAULT_MAX_ENTRIES, + limit: typeof req.limit === 'number' ? Math.max(1, req.limit) : DEFAULT_LIMIT, + respectGitIgnore: req.respectGitIgnore !== false, + ignoreGlobs: req.ignoreGlobs?.length ? req.ignoreGlobs : [], + } +} + +async function readGitIgnore(cwd: string, respectGitIgnore: boolean): Promise { + if (!respectGitIgnore) return '' + try { + return await readFile(join(cwd, '.gitignore'), 'utf8') + } catch { + // ignore read failure + } + return '' +} + +type IgnoreMatcher = Ignore & { __memoSignature: string } + +async function createIgnoreMatcher(opts: NormalizedOptions, cwd: string): Promise { + const content = await readGitIgnore(cwd, opts.respectGitIgnore) + const ig = ignore() + ig.add(DEFAULT_IGNORE) + if (opts.ignoreGlobs.length) { + ig.add(opts.ignoreGlobs) + } + if (content.trim()) { + ig.add(content) + } + const signature = buildSignature(opts, content) + return Object.assign(ig, { __memoSignature: signature }) as IgnoreMatcher +} + +async function scanDirectory( + cwd: string, + opts: NormalizedOptions, + matcher: IgnoreMatcher, +): Promise<{ entries: IndexedEntry[]; signature: string }> { + const entries: IndexedEntry[] = [] + const maxEntries = opts.maxEntries + + const walk = async (dir: string, depth: number) => { + if (entries.length >= maxEntries) return + let dirents + try { + dirents = await readdir(dir, { withFileTypes: true }) + } catch { + return + } + for (const dirent of dirents) { + if (entries.length >= maxEntries) break + if (dirent.isSymbolicLink()) continue + const abs = join(dir, dirent.name) + const rel = relative(cwd, abs) + if (!rel) continue + const normalized = normalizePath(rel) + if (matcher.ignores(normalized)) continue + const segments = normalized.split('/').filter(Boolean) + const segmentsLower = segments.map((s) => s.toLowerCase()) + const isDir = dirent.isDirectory() + entries.push({ + path: normalized, + pathLower: normalized.toLowerCase(), + segments, + segmentsLower, + depth, + isDir, + }) + if (entries.length >= maxEntries) break + if (isDir && depth < opts.maxDepth) { + await walk(abs, depth + 1) + } + } + } + + await walk(cwd, 0) + entries.sort((a, b) => a.path.localeCompare(b.path)) + return { entries, signature: matcher.__memoSignature } +} + +async function ensureEntries(cwd: string, req: FileSuggestionRequest) { + const opts = normalizeOptions(req) + const ig = await createIgnoreMatcher(opts, cwd) + const signature = ig.__memoSignature + const cached = directoryCache.get(cwd) + if (cached && cached.signature === signature) { + if (cached.pending) { + return cached.pending + } + return cached.entries + } + const buildPromise = scanDirectory(cwd, opts, ig) + .then((result) => { + directoryCache.set(cwd, { entries: result.entries, signature: result.signature }) + return result.entries + }) + .catch((err) => { + directoryCache.delete(cwd) + throw err + }) + directoryCache.set(cwd, { entries: [], signature, pending: buildPromise }) + return buildPromise +} + +type RankedEntry = { entry: IndexedEntry; score: number } + +function baseScore(entry: IndexedEntry) { + return entry.depth + (entry.isDir ? -0.2 : 0.2) +} + +function matchTokens(entry: IndexedEntry, tokens: string[]) { + if (!tokens.length) return baseScore(entry) + let score = entry.depth + let cursor = 0 + for (const token of tokens) { + let foundIndex = -1 + for (let idx = cursor; idx < entry.segmentsLower.length; idx++) { + const segment = entry.segmentsLower[idx] as string + if (segment.startsWith(token)) { + foundIndex = idx + score += (idx - cursor) * 1.5 + score += segment.length - token.length + break + } + const pos = segment.indexOf(token) + if (pos !== -1) { + foundIndex = idx + score += (idx - cursor) * 2 + pos + 2 + break + } + } + if (foundIndex === -1) return null + cursor = foundIndex + 1 + } + if (entry.isDir) score -= 0.5 + return score +} + +function rankEntries(entries: IndexedEntry[], query: string, limit: number): FileSuggestion[] { + const normalized = query.trim().replace(/\\/g, '/') + const rawTokens = normalized.split('/').filter(Boolean) + const tokens = rawTokens.map((token) => token.toLowerCase()) + const ranked: RankedEntry[] = [] + + for (const entry of entries) { + const score = matchTokens(entry, tokens) + if (score === null) continue + ranked.push({ entry, score }) + } + + ranked.sort((a, b) => { + const diff = a.score - b.score + if (diff !== 0) return diff + return a.entry.path.localeCompare(b.entry.path) + }) + + return ranked.slice(0, limit).map(({ entry }) => ({ + id: entry.path, + path: entry.path, + name: entry.segments[entry.segments.length - 1] ?? entry.path, + parent: + entry.segments.length > 1 ? entry.segments.slice(0, -1).join('/') : undefined, + isDir: entry.isDir, + })) +} + +/** 获取匹配文件/目录的建议列表,结果按匹配度排序。 */ +export async function getFileSuggestions( + req: FileSuggestionRequest, +): Promise { + const entries = await ensureEntries(req.cwd, req) + const limit = typeof req.limit === 'number' ? Math.max(1, req.limit) : DEFAULT_LIMIT + return rankEntries(entries, req.query, limit) +} + +/** 主动清空某个目录的缓存(例如目录切换)。 */ +export function invalidateFileSuggestionCache(cwd?: string) { + if (cwd) { + directoryCache.delete(cwd) + return + } + directoryCache.clear() +} diff --git a/packages/core/src/runtime/suggestions/history_store.ts b/packages/core/src/runtime/suggestions/history_store.ts new file mode 100644 index 0000000..8bddc9a --- /dev/null +++ b/packages/core/src/runtime/suggestions/history_store.ts @@ -0,0 +1,108 @@ +/** @file CLI 输入历史持久化:按工作目录分桶,并暴露查询接口。 */ +import { mkdir, readFile, writeFile } from 'node:fs/promises' +import { dirname } from 'node:path' +import { randomUUID } from 'node:crypto' +import type { + InputHistoryEntry, + InputHistoryQuery, + InputHistoryStoreOptions, +} from './types' + +const DEFAULT_MAX_ENTRIES = 500 + +export class InputHistoryStore { + private entries: InputHistoryEntry[] = [] + private loaded = false + private pendingWrite: Promise | null = null + + constructor(private options: InputHistoryStoreOptions) {} + + private async ensureLoaded() { + if (this.loaded) return + try { + const raw = await readFile(this.options.filePath, 'utf8') + const parsed = JSON.parse(raw) as InputHistoryEntry[] + if (Array.isArray(parsed)) { + this.entries = parsed + } + } catch { + this.entries = [] + } finally { + this.loaded = true + } + } + + private trimToLimit() { + const maxEntries = this.options.maxEntries ?? DEFAULT_MAX_ENTRIES + if (this.entries.length > maxEntries) { + this.entries = this.entries.slice(this.entries.length - maxEntries) + } + } + + private enqueueWrite() { + const writeTask = async () => { + await mkdir(dirname(this.options.filePath), { recursive: true }) + await writeFile(this.options.filePath, JSON.stringify(this.entries, null, 2), 'utf8') + } + if (this.pendingWrite) { + this.pendingWrite = this.pendingWrite.then(() => writeTask()) + } else { + this.pendingWrite = writeTask() + } + const current = this.pendingWrite + current + .catch(() => { + // swallow write errors,读取时再恢复 + }) + .finally(() => { + if (this.pendingWrite === current) { + this.pendingWrite = null + } + }) + return current + } + + /** 记录新的历史输入,按 cwd 聚合,避免重复堆积。 */ + async record(entry: { cwd: string; input: string; sessionFile?: string }) { + const trimmed = entry.input.trim() + if (!trimmed) return + await this.ensureLoaded() + const now = Date.now() + this.entries = this.entries.filter( + (item) => !(item.cwd === entry.cwd && item.input === trimmed), + ) + const newEntry: InputHistoryEntry = { + id: randomUUID(), + cwd: entry.cwd, + input: trimmed, + ts: now, + sessionFile: entry.sessionFile, + } + this.entries.push(newEntry) + this.trimToLimit() + await this.enqueueWrite().catch(() => { + // ignore fs errors + }) + } + + /** 查询当前工作目录下的历史记录,按时间逆序排序并支持关键字过滤。 */ + async query(query: InputHistoryQuery): Promise { + await this.ensureLoaded() + const keyword = query.keyword?.trim().toLowerCase() + const limit = query.limit ?? 20 + const exactMatches = this.entries + .filter((entry) => entry.cwd === query.cwd) + .sort((a, b) => b.ts - a.ts) + + let filtered = keyword + ? exactMatches.filter((entry) => entry.input.toLowerCase().includes(keyword)) + : exactMatches + + if (typeof query.beforeTs === 'number') { + const beforeTs = query.beforeTs + filtered = filtered.filter((entry) => entry.ts <= beforeTs) + } + + return filtered.slice(0, limit) + } +} diff --git a/packages/core/src/runtime/suggestions/index.ts b/packages/core/src/runtime/suggestions/index.ts new file mode 100644 index 0000000..39984c2 --- /dev/null +++ b/packages/core/src/runtime/suggestions/index.ts @@ -0,0 +1,3 @@ +export * from './types' +export * from './file_suggestions' +export * from './history_store' diff --git a/packages/core/src/runtime/suggestions/types.ts b/packages/core/src/runtime/suggestions/types.ts new file mode 100644 index 0000000..c43a0bc --- /dev/null +++ b/packages/core/src/runtime/suggestions/types.ts @@ -0,0 +1,49 @@ +/** @file 自动补全/建议体系的公共类型定义。 */ + +export type FileSuggestion = { + /** 用于 UI key 的唯一 ID,默认等于 path。 */ + id: string + /** 相对工作目录的 POSIX 风格路径。 */ + path: string + /** 末级文件/文件夹名。 */ + name: string + /** 父级路径(若位于根目录则为空)。 */ + parent?: string + /** 是否为目录,用于区分样式和追加斜杠。 */ + isDir: boolean +} + +export type FileSuggestionRequest = { + cwd: string + query: string + limit?: number + /** 最大递归深度,默认为 6 层。 */ + maxDepth?: number + /** 单次扫描的最大条目数,默认为 2500。 */ + maxEntries?: number + /** 是否解析 .gitignore,默认 true。 */ + respectGitIgnore?: boolean + /** 附加忽略 glob 列表。 */ + ignoreGlobs?: string[] +} + +export type InputHistoryEntry = { + id: string + cwd: string + input: string + ts: number + sessionFile?: string +} + +export type InputHistoryQuery = { + cwd: string + keyword?: string + limit?: number + /** 仅返回该时间戳之前的记录,用于排除当前 Session。 */ + beforeTs?: number +} + +export type InputHistoryStoreOptions = { + filePath: string + maxEntries?: number +} diff --git a/packages/core/src/types.ts b/packages/core/src/types.ts index 97f2415..2cf3fec 100644 --- a/packages/core/src/types.ts +++ b/packages/core/src/types.ts @@ -75,9 +75,14 @@ export type ParsedAssistant = { export type ToolRegistry = Record> /** LLM 调用接口:输入历史消息,返回模型回复文本或携带 usage,可选流式回调。 */ +export type CallLLMOptions = { + signal?: AbortSignal +} + export type CallLLM = ( messages: ChatMessage[], onChunk?: (chunk: string) => void, + options?: CallLLMOptions, ) => Promise /** @@ -138,7 +143,7 @@ export type AgentSessionDeps = AgentDeps & { } /** 单轮对话的状态码。 */ -export type TurnStatus = 'ok' | 'error' | 'max_steps' | 'prompt_limit' +export type TurnStatus = 'ok' | 'error' | 'max_steps' | 'prompt_limit' | 'cancelled' /** 单轮对话的运行结果(含步骤与 token)。 */ export type TurnResult = { @@ -211,8 +216,12 @@ export type AgentSession = { mode: SessionMode /** 当前对话历史。 */ history: ChatMessage[] + /** 当前 Session 日志文件路径(若存在)。 */ + historyFilePath?: string /** 执行一轮对话。 */ runTurn: (input: string) => Promise + /** 取消当前运行中的 turn(若支持)。 */ + cancelCurrentTurn?: (reason?: string) => void /** 结束 Session,释放资源。 */ close: () => Promise } diff --git a/packages/ui/src/index.tsx b/packages/ui/src/index.tsx index ccee46a..f052172 100644 --- a/packages/ui/src/index.tsx +++ b/packages/ui/src/index.tsx @@ -8,6 +8,7 @@ import { loadMemoConfig, writeMemoConfig, selectProvider, + getSessionsDir, type AgentSessionDeps, type AgentSessionOptions, type MemoConfig, @@ -170,6 +171,7 @@ async function runInteractiveTui(parsed: ParsedArgs) { mode: 'interactive', stream: loaded.config.stream_output ?? false, } + const sessionsDir = getSessionsDir(loaded, sessionOptions) const app = render( , { exitOnCtrlC: false }, ) diff --git a/packages/ui/src/tui/App.tsx b/packages/ui/src/tui/App.tsx index 46dcf17..3fd6a1d 100644 --- a/packages/ui/src/tui/App.tsx +++ b/packages/ui/src/tui/App.tsx @@ -1,12 +1,15 @@ import { useCallback, useEffect, useMemo, useRef, useState } from 'react' +import { readFile } from 'node:fs/promises' import { Box, useApp } from 'ink' import { createAgentSession, type AgentSession, type AgentSessionDeps, type AgentSessionOptions, + type ChatMessage, + type InputHistoryEntry, } from '@memo/core' -import type { SystemMessage, TurnView } from './types' +import type { StepView, SystemMessage, TurnView } from './types' import { HeaderBar } from './components/layout/HeaderBar' import { TokenBar } from './components/layout/TokenBar' import { MainContent } from './components/layout/MainContent' @@ -21,6 +24,7 @@ export type AppProps = { configPath: string mcpServerNames: string[] cwd: string + sessionsDir: string } function createEmptyTurn(index: number): TurnView { @@ -34,6 +38,7 @@ export function App({ configPath, mcpServerNames, cwd, + sessionsDir, }: AppProps) { const { exit } = useApp() const [session, setSession] = useState(null) @@ -43,6 +48,9 @@ export function App({ const [busy, setBusy] = useState(false) const currentTurnRef = useRef(null) const [inputHistory, setInputHistory] = useState([]) + const [sessionLogPath, setSessionLogPath] = useState(null) + const [historicalTurns, setHistoricalTurns] = useState([]) + const [pendingHistoryMessages, setPendingHistoryMessages] = useState(null) const appendSystemMessage = useCallback((title: string, content: string) => { const id = `${Date.now()}-${Math.random().toString(16).slice(2)}` @@ -156,6 +164,7 @@ export function App({ return } setSession(created) + setSessionLogPath(created.historyFilePath ?? null) })() return () => { active = false @@ -181,8 +190,38 @@ export function App({ setTurns([]) setSystemMessages([]) setStatusMessage(null) + setHistoricalTurns([]) + setPendingHistoryMessages(null) }, []) + const handleHistorySelect = useCallback( + async (entry: InputHistoryEntry) => { + if (!entry.sessionFile) { + appendSystemMessage('历史记录', '该记录没有可加载的上下文文件。') + return + } + try { + const raw = await readFile(entry.sessionFile, 'utf8') + const parsed = parseHistoryLog(raw) + setHistoricalTurns(parsed.turns) + setPendingHistoryMessages(parsed.messages) + setTurns([]) + appendSystemMessage('历史记录已加载', parsed.summary || entry.input) + } catch (err) { + appendSystemMessage( + '历史记录加载失败', + `无法读取 ${entry.sessionFile}: ${(err as Error).message}`, + ) + } + }, + [appendSystemMessage], + ) + + const handleCancelRun = useCallback(() => { + if (!busy) return + session?.cancelCurrentTurn?.() + }, [busy, session]) + const handleCommand = useCallback( async (raw: string) => { const result = resolveSlashCommand(raw, { @@ -221,6 +260,10 @@ export function App({ return } setInputHistory((prev) => [...prev, value]) + if (pendingHistoryMessages?.length && session) { + session.history.push(...pendingHistoryMessages) + setPendingHistoryMessages(null) + } setBusy(true) try { await session.runTurn(value) @@ -229,7 +272,7 @@ export function App({ setBusy(false) } }, - [busy, handleCommand, session], + [busy, handleCommand, session, pendingHistoryMessages], ) const lastTurn = turns[turns.length - 1] @@ -238,12 +281,14 @@ export function App({ statusMessage !== null ? 'error' : !session ? 'initializing' : busy ? 'running' : 'ready' const tokenLine = formatTokenUsage(lastTurn?.tokenUsage) + const displayTurns = useMemo(() => [...historicalTurns, ...turns], [historicalTurns, turns]) + return ( @@ -252,9 +297,76 @@ export function App({ onSubmit={handleSubmit} onExit={handleExit} onClear={handleClear} + onCancelRun={handleCancelRun} + onHistorySelect={handleHistorySelect} history={inputHistory} + cwd={cwd} + sessionsDir={sessionsDir} + currentSessionFile={sessionLogPath ?? undefined} /> ) } + +function parseHistoryLog(raw: string): { + summary: string + messages: ChatMessage[] + turns: TurnView[] +} { + const messages: ChatMessage[] = [] + const turns: TurnView[] = [] + const summaryParts: string[] = [] + const lines = raw.split('\n').map((line) => line.trim()).filter(Boolean) + let currentTurn: TurnView | null = null + let turnCount = 0 + + for (const line of lines) { + let event: any + try { + event = JSON.parse(line) + } catch { + continue + } + if (!event || typeof event !== 'object') continue + if (event.type === 'turn_start') { + const userInput = typeof event.content === 'string' ? event.content : '' + const index = -(turnCount + 1) + currentTurn = { + index, + userInput, + steps: [], + status: 'ok', + } + turns.push(currentTurn) + if (userInput) { + messages.push({ role: 'user', content: userInput }) + summaryParts.push(`User: ${userInput}`) + } + turnCount += 1 + continue + } + if (event.type === 'assistant') { + const assistantText = typeof event.content === 'string' ? event.content : '' + if (assistantText) { + messages.push({ role: 'assistant', content: assistantText }) + summaryParts.push(`Assistant: ${assistantText}`) + if (currentTurn) { + const step: StepView = { + index: currentTurn.steps.length, + assistantText, + } + currentTurn.steps = [...currentTurn.steps, step] + currentTurn.finalText = assistantText + } + } + continue + } + } + + return { + summary: summaryParts.join('\n'), + messages, + turns, + } +} diff --git a/packages/ui/src/tui/commands.ts b/packages/ui/src/tui/commands.ts index 7b15912..8564456 100644 --- a/packages/ui/src/tui/commands.ts +++ b/packages/ui/src/tui/commands.ts @@ -1,7 +1,4 @@ -import { TOOLKIT } from '@memo/tools' -import { HELP_TEXT } from './constants' - -export type SlashCommandContext = { +export type SlashResolveContext = { configPath: string providerName: string model: string @@ -13,32 +10,18 @@ export type SlashCommandResult = | { kind: 'clear' } | { kind: 'message'; title: string; content: string } -export function resolveSlashCommand(raw: string, context: SlashCommandContext): SlashCommandResult { +export function resolveSlashCommand(raw: string, context: SlashResolveContext): SlashCommandResult { const [command] = raw.trim().slice(1).split(/\s+/) switch (command) { - case 'help': - return { kind: 'message', title: 'help', content: HELP_TEXT } case 'exit': return { kind: 'exit' } case 'clear': return { kind: 'clear' } - case 'tools': { - const builtin = Object.keys(TOOLKIT).sort() - const external = - context.mcpServerNames.length > 0 - ? `MCP servers: ${context.mcpServerNames.join(', ')}` - : 'MCP servers: (none)' - return { - kind: 'message', - title: 'tools', - content: `Built-in tools (${builtin.length}): ${builtin.join(', ')}\n${external}`, - } - } - case 'config': + case 'history': return { kind: 'message', - title: 'config', - content: `config: ${context.configPath}\nprovider: ${context.providerName}\nmodel: ${context.model}`, + title: 'history', + content: '输入 "history" 能够按当前工作目录筛选历史记录并选择回填。', } default: return { kind: 'message', title: 'unknown', content: `Unknown command: ${raw}` } diff --git a/packages/ui/src/tui/components/input/SuggestionList.tsx b/packages/ui/src/tui/components/input/SuggestionList.tsx new file mode 100644 index 0000000..05d66e5 --- /dev/null +++ b/packages/ui/src/tui/components/input/SuggestionList.tsx @@ -0,0 +1,62 @@ +import { Box, Text } from 'ink' + +export type SuggestionListItem = { + id: string + title: string + subtitle?: string + kind: 'file' | 'history' | 'slash' + badge?: string +} + +type SuggestionListProps = { + items: SuggestionListItem[] + activeIndex: number + loading: boolean +} + +const ACTIVE_COLOR = '#4ec9ff' +const INACTIVE_COLOR = '#f0f0f0' +const SUBTITLE_COLOR = '#a0a0a0' +const EMPTY_COLOR = '#666666' +const SLASH_DESC_COLOR = '#dadada' + +export function SuggestionList({ items, activeIndex, loading }: SuggestionListProps) { + if (loading) { + return ( + + 加载中... + + ) + } + if (!items.length) { + return ( + + 无匹配项 + + ) + } + return ( + + {items.map((item, index) => { + const isActive = index === activeIndex + if (item.kind === 'slash') { + const commandColor = isActive ? ACTIVE_COLOR : INACTIVE_COLOR + const descColor = isActive ? SLASH_DESC_COLOR : SUBTITLE_COLOR + return ( + + {item.title} + {item.subtitle ? {item.subtitle} : null} + + ) + } + const titleColor = isActive ? ACTIVE_COLOR : INACTIVE_COLOR + return ( + + {item.title} + {item.subtitle ? {item.subtitle} : null} + + ) + })} + + ) +} diff --git a/packages/ui/src/tui/components/layout/InputPrompt.tsx b/packages/ui/src/tui/components/layout/InputPrompt.tsx index c37c59f..977a77f 100644 --- a/packages/ui/src/tui/components/layout/InputPrompt.tsx +++ b/packages/ui/src/tui/components/layout/InputPrompt.tsx @@ -1,21 +1,228 @@ +import { useCallback, useEffect, useMemo, useRef, useState } from 'react' +import { readFile, readdir, stat } from 'node:fs/promises' +import { basename, join, resolve } from 'node:path' import { Box, Text, useInput, useStdout } from 'ink' -import { useState } from 'react' +import { getFileSuggestions, getSessionLogDir, type InputHistoryEntry } from '@memo/core' import { USER_PREFIX } from '../../constants' import { buildPaddedLine } from '../../utils' +import { SuggestionList, type SuggestionListItem } from '../input/SuggestionList' +import { SLASH_COMMANDS, type SlashCommand } from '../../slash' + +const DOUBLE_ESC_WINDOW_MS = 400 type InputPromptProps = { disabled: boolean onSubmit: (value: string) => void onExit: () => void onClear: () => void + onCancelRun: () => void + onHistorySelect?: (entry: InputHistoryEntry) => void history: string[] + cwd: string + sessionsDir: string + currentSessionFile?: string +} + +type SuggestionMode = 'none' | 'file' | 'history' | 'slash' + +type SuggestionItem = SuggestionListItem & { + value: string + meta?: { isDir?: boolean; slashCommand?: SlashCommand; historyEntry?: InputHistoryEntry } } -export function InputPrompt({ disabled, onSubmit, onExit, onClear, history }: InputPromptProps) { +type FileTrigger = { type: 'file'; query: string; tokenStart: number } +type HistoryTrigger = { type: 'history'; keyword: string } +type SlashTrigger = { type: 'slash'; keyword: string } +type SuggestionTrigger = FileTrigger | HistoryTrigger | SlashTrigger + +export function InputPrompt({ + disabled, + onSubmit, + onExit, + onClear, + onCancelRun, + history, + cwd, + sessionsDir, + currentSessionFile, + onHistorySelect, +}: InputPromptProps) { const { stdout } = useStdout() const [value, setValue] = useState('') const [historyIndex, setHistoryIndex] = useState(null) const [draft, setDraft] = useState('') + const [suggestionMode, setSuggestionMode] = useState('none') + const [suggestionItems, setSuggestionItems] = useState([]) + const [activeIndex, setActiveIndex] = useState(0) + const [loadingSuggestions, setLoadingSuggestions] = useState(false) + const [suppressSuggestions, setSuppressSuggestions] = useState(false) + const requestIdRef = useRef(0) + const lastEscTimeRef = useRef(0) + + useEffect(() => { + setSuppressSuggestions(false) + }, [value]) + + const trigger = useMemo(() => { + if (suppressSuggestions || disabled) return null + return detectSuggestionTrigger(value) + }, [disabled, suppressSuggestions, value]) + + const closeSuggestions = useCallback( + (suppress = true) => { + if (suppress) { + setSuppressSuggestions(true) + } + setSuggestionMode('none') + setSuggestionItems([]) + setActiveIndex(0) + setLoadingSuggestions(false) + }, + [], + ) + + useEffect(() => { + if (disabled) { + closeSuggestions(false) + } + }, [disabled, closeSuggestions]) + + useEffect(() => { + if (!trigger) { + setSuggestionMode('none') + setSuggestionItems([]) + setActiveIndex(0) + setLoadingSuggestions(false) + return + } + let cancelled = false + const requestId = ++requestIdRef.current + setLoadingSuggestions(true) + ;(async () => { + try { + if (trigger.type === 'file') { + const matches = await getFileSuggestions({ + cwd, + query: trigger.query, + limit: 8, + }) + if (cancelled || requestId !== requestIdRef.current) return + const mapped = matches.map((match) => { + const displayPath = match.isDir ? `${match.path}/` : match.path + return { + id: match.id, + title: displayPath, + kind: 'file' as const, + value: displayPath, + meta: { isDir: match.isDir }, + } + }) + setSuggestionMode('file') + setSuggestionItems(mapped) + setActiveIndex((prev) => + mapped.length ? Math.min(prev, mapped.length - 1) : 0, + ) + return + } + if (trigger.type === 'history') { + const entries = await loadSessionHistoryEntries({ + sessionsDir, + cwd, + keyword: trigger.keyword, + activeSessionFile: currentSessionFile, + }) + if (cancelled || requestId !== requestIdRef.current) return + const mapped = entries.map(mapHistoryEntry) + setSuggestionMode('history') + setSuggestionItems(mapped) + setActiveIndex((prev) => + mapped.length ? Math.min(prev, mapped.length - 1) : 0, + ) + return + } + if (trigger.type === 'slash') { + const keyword = trigger.keyword.toLowerCase() + const filtered = keyword + ? SLASH_COMMANDS.filter((cmd) => + cmd.matches ? cmd.matches(keyword) : cmd.name.startsWith(keyword), + ) + : SLASH_COMMANDS + const mapped = filtered.map((cmd) => ({ + id: cmd.name, + title: `/${cmd.name}`, + subtitle: cmd.description, + kind: 'slash' as const, + value: `/${cmd.name} `, + meta: { slashCommand: cmd }, + })) + setSuggestionMode('slash') + setSuggestionItems(mapped) + setActiveIndex((prev) => + mapped.length ? Math.min(prev, mapped.length - 1) : 0, + ) + return + } + } catch { + if (!cancelled && requestId === requestIdRef.current) { + setSuggestionItems([]) + } + } finally { + if (!cancelled && requestId === requestIdRef.current) { + setLoadingSuggestions(false) + } + } + })() + return () => { + cancelled = true + } + }, [trigger, cwd, sessionsDir, currentSessionFile]) + + const applySuggestion = useCallback( + (item?: SuggestionItem) => { + if (!item) return + if (suggestionMode === 'file' && trigger?.type === 'file') { + const prefix = value.slice(0, trigger.tokenStart) + const suffix = value.slice(trigger.tokenStart + trigger.query.length) + const nextValue = `${prefix}${item.value}${suffix}` + setValue(nextValue) + setHistoryIndex(null) + setDraft('') + if (!item.meta?.isDir) { + closeSuggestions() + } + return + } + if (suggestionMode === 'history') { + if (item.meta?.historyEntry) { + onHistorySelect?.(item.meta.historyEntry) + } + setValue(item.value) + setHistoryIndex(null) + setDraft('') + closeSuggestions() + return + } + if (suggestionMode === 'slash' && item.meta?.slashCommand) { + const slashCommand = item.meta.slashCommand + slashCommand.run({ + setInputValue: (next) => { + setValue(next) + setHistoryIndex(null) + setDraft('') + }, + closeSuggestions, + clearScreen: () => { + onClear() + }, + exitApp: () => { + onExit() + }, + }) + return + } + }, + [closeSuggestions, onClear, onExit, suggestionMode, trigger, value], + ) useInput((input, key) => { if (key.ctrl && input === 'c') { @@ -26,26 +233,46 @@ export function InputPrompt({ disabled, onSubmit, onExit, onClear, history }: In setValue('') setHistoryIndex(null) setDraft('') + closeSuggestions() onClear() return } - if (disabled) { + const hasSuggestionList = suggestionMode !== 'none' + const canNavigate = hasSuggestionList && suggestionItems.length > 0 + + if (key.escape) { + const now = Date.now() + if (now - lastEscTimeRef.current <= DOUBLE_ESC_WINDOW_MS) { + lastEscTimeRef.current = 0 + if (disabled) { + onCancelRun() + } else { + setValue('') + setHistoryIndex(null) + setDraft('') + closeSuggestions() + } + return + } + lastEscTimeRef.current = now + if (hasSuggestionList) { + closeSuggestions() + } return } - if (key.return) { - const trimmed = value.trim() - if (trimmed) { - onSubmit(trimmed) - setValue('') - setHistoryIndex(null) - setDraft('') - } + if (disabled) { return } if (key.upArrow) { + if (canNavigate) { + setActiveIndex((prev) => + prev <= 0 ? suggestionItems.length - 1 : prev - 1, + ) + return + } if (!history.length) return if (historyIndex === null) { setDraft(value) @@ -61,6 +288,10 @@ export function InputPrompt({ disabled, onSubmit, onExit, onClear, history }: In } if (key.downArrow) { + if (canNavigate) { + setActiveIndex((prev) => (prev + 1) % suggestionItems.length) + return + } if (historyIndex === null) return const nextIndex = historyIndex + 1 if (nextIndex >= history.length) { @@ -74,6 +305,27 @@ export function InputPrompt({ disabled, onSubmit, onExit, onClear, history }: In return } + if (key.tab && canNavigate) { + applySuggestion(suggestionItems[activeIndex]) + return + } + + if (key.return) { + if (canNavigate) { + applySuggestion(suggestionItems[activeIndex]) + return + } + const trimmed = value.trim() + if (trimmed) { + onSubmit(trimmed) + setValue('') + setHistoryIndex(null) + setDraft('') + closeSuggestions(false) + } + return + } + if (key.backspace || key.delete) { setValue((prev) => prev.slice(0, Math.max(0, prev.length - 1))) return @@ -94,13 +346,211 @@ export function InputPrompt({ disabled, onSubmit, onExit, onClear, history }: In ) const verticalPadding = 1 + const suggestionListItems: SuggestionListItem[] = suggestionItems.map( + ({ value: _value, meta: _meta, ...rest }) => rest, + ) + return ( - - {verticalPadding > 0 ? {blankLine} : null} - - {line} - - {verticalPadding > 0 ? {blankLine} : null} + + + {verticalPadding > 0 ? {blankLine} : null} + + {line} + + {verticalPadding > 0 ? {blankLine} : null} + + {suggestionMode !== 'none' ? ( + + ) : null} ) } + +type HistoryLoadOptions = { + sessionsDir: string + cwd: string + keyword?: string + activeSessionFile?: string + limit?: number +} + +async function loadSessionHistoryEntries(options: HistoryLoadOptions): Promise { + const logDir = getSessionLogDir(options.sessionsDir, options.cwd) + let dirEntries: import('node:fs').Dirent[] + try { + dirEntries = await readdir(logDir, { withFileTypes: true }) + } catch (err) { + if ((err as NodeJS.ErrnoException)?.code === 'ENOENT') { + return [] + } + return [] + } + const normalizedActive = options.activeSessionFile + ? resolve(options.activeSessionFile) + : null + const candidates = await Promise.all( + dirEntries + .filter((entry) => entry.isFile() && entry.name.endsWith('.jsonl')) + .map(async (entry) => { + const fullPath = join(logDir, entry.name) + if (normalizedActive && resolve(fullPath) === normalizedActive) { + return null + } + try { + const info = await stat(fullPath) + return { path: fullPath, mtimeMs: info.mtimeMs } + } catch { + return null + } + }), + ) + const sorted = candidates + .filter((item): item is { path: string; mtimeMs: number } => Boolean(item)) + .sort((a, b) => b.mtimeMs - a.mtimeMs) + const limit = options.limit ?? 10 + const keyword = options.keyword?.trim().toLowerCase() + const entries: InputHistoryEntry[] = [] + for (const candidate of sorted) { + if (entries.length >= limit) break + const entry = await buildHistoryEntryFromFile(candidate.path, options.cwd, candidate.mtimeMs) + if (!entry) continue + if (keyword && !entry.input.toLowerCase().includes(keyword)) { + continue + } + entries.push(entry) + if (entries.length >= limit) break + } + return entries +} + +async function buildHistoryEntryFromFile( + filePath: string, + cwd: string, + ts: number, +): Promise { + try { + const raw = await readFile(filePath, 'utf8') + const firstPrompt = extractFirstTurnStart(raw) + const title = firstPrompt?.trim() || formatSessionFileName(filePath) + return { + id: filePath, + cwd, + input: title, + ts, + sessionFile: filePath, + } + } catch { + return null + } +} + +function extractFirstTurnStart(raw: string): string | null { + for (const line of raw.split('\n')) { + const trimmed = line.trim() + if (!trimmed) continue + let event: any + try { + event = JSON.parse(trimmed) + } catch { + continue + } + if (event && typeof event === 'object' && event.type === 'turn_start') { + const content = typeof event.content === 'string' ? event.content.trim() : '' + if (content) { + return content + } + } + } + return null +} + +function formatSessionFileName(filePath: string) { + return basename(filePath).replace(/\.jsonl$/i, '') +} + +function detectSuggestionTrigger(value: string): SuggestionTrigger | null { + const slashTrigger = detectSlashTrigger(value) + if (slashTrigger) return slashTrigger + const fileTrigger = detectFileTrigger(value) + if (fileTrigger) return fileTrigger + return detectHistoryTrigger(value) +} + +function detectFileTrigger(value: string): FileTrigger | null { + const atIndex = value.lastIndexOf('@') + if (atIndex === -1) return null + if (atIndex > 0) { + const prevChar = value[atIndex - 1] + if (prevChar && !/\s/.test(prevChar)) { + return null + } + } + const after = value.slice(atIndex + 1) + if (/\s/.test(after)) return null + return { + type: 'file', + query: after, + tokenStart: atIndex + 1, + } +} + +function detectHistoryTrigger(value: string): HistoryTrigger | null { + const trimmedStart = value.trimStart() + const prefixLength = value.length - trimmedStart.length + if (trimmedStart.length === 0) return null + let normalized = trimmedStart + if (normalized.startsWith('/')) { + normalized = normalized.slice(1) + } + if (!normalized.toLowerCase().startsWith('history')) return null + const hasOtherPrefix = value.slice(0, prefixLength).trim().length > 0 + if (hasOtherPrefix) return null + const rest = normalized.slice('history'.length) + if (rest && !rest.startsWith(' ')) return null + return { + type: 'history', + keyword: rest.trim(), + } +} + +function detectSlashTrigger(value: string): SlashTrigger | null { + const trimmedStart = value.trimStart() + if (!trimmedStart.startsWith('/')) return null + const keyword = trimmedStart.slice(1) + if (keyword.includes(' ')) return null + if (/^[a-zA-Z]*$/.test(keyword)) { + return { type: 'slash', keyword: keyword.toLowerCase() } + } + if (keyword.length === 0) { + return { type: 'slash', keyword: '' } + } + return null +} + +function mapHistoryEntry(entry: InputHistoryEntry): SuggestionItem { + return { + id: entry.id, + title: entry.input, + subtitle: formatHistoryTimestamp(entry.ts), + kind: 'history', + badge: 'HIS', + value: entry.input, + meta: { historyEntry: entry }, + } +} + +function formatHistoryTimestamp(ts: number) { + if (!ts) return '' + const date = new Date(ts) + if (Number.isNaN(date.getTime())) return '' + const yyyy = String(date.getFullYear()) + const mm = String(date.getMonth() + 1).padStart(2, '0') + const dd = String(date.getDate()).padStart(2, '0') + const HH = String(date.getHours()).padStart(2, '0') + const MM = String(date.getMinutes()).padStart(2, '0') + return `${yyyy}-${mm}-${dd} ${HH}:${MM}` +} diff --git a/packages/ui/src/tui/slash/clear.ts b/packages/ui/src/tui/slash/clear.ts new file mode 100644 index 0000000..0402b65 --- /dev/null +++ b/packages/ui/src/tui/slash/clear.ts @@ -0,0 +1,11 @@ +import type { SlashCommand } from './types' + +export const clearCommand: SlashCommand = { + name: 'clear', + description: '清空屏幕', + run: ({ closeSuggestions, setInputValue, clearScreen }) => { + closeSuggestions() + setInputValue('') + clearScreen() + }, +} diff --git a/packages/ui/src/tui/slash/exit.ts b/packages/ui/src/tui/slash/exit.ts new file mode 100644 index 0000000..b473dfb --- /dev/null +++ b/packages/ui/src/tui/slash/exit.ts @@ -0,0 +1,10 @@ +import type { SlashCommand } from './types' + +export const exitCommand: SlashCommand = { + name: 'exit', + description: '退出当前会话', + run: ({ closeSuggestions, exitApp }) => { + closeSuggestions() + exitApp() + }, +} diff --git a/packages/ui/src/tui/slash/history.ts b/packages/ui/src/tui/slash/history.ts new file mode 100644 index 0000000..021a355 --- /dev/null +++ b/packages/ui/src/tui/slash/history.ts @@ -0,0 +1,10 @@ +import type { SlashCommand } from './types' + +export const historyCommand: SlashCommand = { + name: 'history', + description: '查看历史输入', + run: ({ setInputValue, closeSuggestions }) => { + closeSuggestions(false) + setInputValue('history ') + }, +} diff --git a/packages/ui/src/tui/slash/index.ts b/packages/ui/src/tui/slash/index.ts new file mode 100644 index 0000000..0cf6c9c --- /dev/null +++ b/packages/ui/src/tui/slash/index.ts @@ -0,0 +1,8 @@ +import type { SlashCommand } from './types' +import { clearCommand } from './clear' +import { exitCommand } from './exit' +import { historyCommand } from './history' + +export const SLASH_COMMANDS: SlashCommand[] = [exitCommand, clearCommand, historyCommand] + +export type { SlashCommand, SlashCommandContext } from './types' diff --git a/packages/ui/src/tui/slash/types.ts b/packages/ui/src/tui/slash/types.ts new file mode 100644 index 0000000..2f511d7 --- /dev/null +++ b/packages/ui/src/tui/slash/types.ts @@ -0,0 +1,18 @@ +export type SlashCommandContext = { + /** 更新输入框内容,并重置历史导航状态。 */ + setInputValue: (next: string) => void + /** 关闭建议面板,suppress=false 时允许立即重新触发。 */ + closeSuggestions: (suppress?: boolean) => void + /** 触发外层清屏逻辑。 */ + clearScreen: () => void + /** 触发退出逻辑。 */ + exitApp: () => void +} + +export type SlashCommand = { + name: string + description: string + /** 是否匹配当前关键字,默认走前缀匹配。 */ + matches?: (keyword: string) => boolean + run: (ctx: SlashCommandContext) => void +} From e9072295495656ad736fb3b7b2517826deb93737 Mon Sep 17 00:00:00 2001 From: minorcell Date: Mon, 22 Dec 2025 18:56:28 +0800 Subject: [PATCH 2/4] =?UTF-8?q?feat:=20=E6=B7=BB=E5=8A=A0=20react-devtools?= =?UTF-8?q?-core=20=E4=BE=9D=E8=B5=96?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- bun.lock | 7 +++++++ package.json | 1 + 2 files changed, 8 insertions(+) diff --git a/bun.lock b/bun.lock index 0e9616e..dd722a2 100644 --- a/bun.lock +++ b/bun.lock @@ -8,6 +8,7 @@ "@dqbd/tiktoken": "^1.0.22", "@modelcontextprotocol/sdk": "^1.24.3", "openai": "^6.10.0", + "react-devtools-core": "^7.0.1", "toml": "^3.0.0", }, "devDependencies": { @@ -261,6 +262,8 @@ "react": ["react@18.3.1", "https://registry.npmmirror.com/react/-/react-18.3.1.tgz", { "dependencies": { "loose-envify": "^1.1.0" } }, "sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ=="], + "react-devtools-core": ["react-devtools-core@7.0.1", "https://registry.npmmirror.com/react-devtools-core/-/react-devtools-core-7.0.1.tgz", { "dependencies": { "shell-quote": "^1.6.1", "ws": "^7" } }, "sha512-C3yNvRHaizlpiASzy7b9vbnBGLrhvdhl1CbdU6EnZgxPNbai60szdLtl+VL76UNOt5bOoVTOz5rNWZxgGt+Gsw=="], + "react-reconciler": ["react-reconciler@0.29.2", "https://registry.npmmirror.com/react-reconciler/-/react-reconciler-0.29.2.tgz", { "dependencies": { "loose-envify": "^1.1.0", "scheduler": "^0.23.2" }, "peerDependencies": { "react": "^18.3.1" } }, "sha512-zZQqIiYgDCTP/f1N/mAR10nJGrPD2ZR+jDSEsKWJHYC7Cm2wodlwbR3upZRdC3cjIjSlTLNVyO7Iu0Yy7t2AYg=="], "require-from-string": ["require-from-string@2.0.2", "https://registry.npmmirror.com/require-from-string/-/require-from-string-2.0.2.tgz", {}, "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw=="], @@ -283,6 +286,8 @@ "shebang-regex": ["shebang-regex@3.0.0", "https://registry.npmmirror.com/shebang-regex/-/shebang-regex-3.0.0.tgz", {}, "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A=="], + "shell-quote": ["shell-quote@1.8.3", "https://registry.npmmirror.com/shell-quote/-/shell-quote-1.8.3.tgz", {}, "sha512-ObmnIF4hXNg1BqhnHmgbDETF8dLPCggZWBjkQfhZpbszZnYur5DUljTcCHii5LC3J5E0yeO/1LIMyH+UvHQgyw=="], + "side-channel": ["side-channel@1.1.0", "https://registry.npmmirror.com/side-channel/-/side-channel-1.1.0.tgz", { "dependencies": { "es-errors": "^1.3.0", "object-inspect": "^1.13.3", "side-channel-list": "^1.0.0", "side-channel-map": "^1.0.1", "side-channel-weakmap": "^1.0.2" } }, "sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw=="], "side-channel-list": ["side-channel-list@1.0.0", "https://registry.npmmirror.com/side-channel-list/-/side-channel-list-1.0.0.tgz", { "dependencies": { "es-errors": "^1.3.0", "object-inspect": "^1.13.3" } }, "sha512-FCLHtRD/gnpCiCHEiJLOwdmFP+wzCmDEkc9y7NsYxeF4u7Btsn1ZuwgwJGxImImHicJArLP4R0yX4c2KCrMrTA=="], @@ -337,6 +342,8 @@ "cli-truncate/slice-ansi": ["slice-ansi@5.0.0", "https://registry.npmmirror.com/slice-ansi/-/slice-ansi-5.0.0.tgz", { "dependencies": { "ansi-styles": "^6.0.0", "is-fullwidth-code-point": "^4.0.0" } }, "sha512-FC+lgizVPfie0kkhqUScwRu1O/lF6NOgJmlCgK+/LYxDCTk8sGelYaHDhFcDN+Sn3Cv+3VSa4Byeo+IMCzpMgQ=="], + "react-devtools-core/ws": ["ws@7.5.10", "https://registry.npmmirror.com/ws/-/ws-7.5.10.tgz", { "peerDependencies": { "bufferutil": "^4.0.1", "utf-8-validate": "^5.0.2" }, "optionalPeers": ["bufferutil", "utf-8-validate"] }, "sha512-+dbF1tHwZpXcbOJdVOkzLDxZP1ailvSxM6ZweXTegylPny803bFhA+vqBYw4s31NSAk4S2Qz+AKXK9a4wkdjcQ=="], + "slice-ansi/is-fullwidth-code-point": ["is-fullwidth-code-point@5.1.0", "https://registry.npmmirror.com/is-fullwidth-code-point/-/is-fullwidth-code-point-5.1.0.tgz", { "dependencies": { "get-east-asian-width": "^1.3.1" } }, "sha512-5XHYaSyiqADb4RnZ1Bdad6cPp8Toise4TzEjcOYDHZkTCbKgiUl7WTUCpNWHuxmDt91wnsZBc9xinNzopv3JMQ=="], } } diff --git a/package.json b/package.json index b28ff11..48edd99 100644 --- a/package.json +++ b/package.json @@ -23,6 +23,7 @@ "@dqbd/tiktoken": "^1.0.22", "@modelcontextprotocol/sdk": "^1.24.3", "openai": "^6.10.0", + "react-devtools-core": "^7.0.1", "toml": "^3.0.0" } } From 7c79d3e8d7b73dcf036223b84520f7a4aa40ae8b Mon Sep 17 00:00:00 2001 From: minorcell Date: Sat, 27 Dec 2025 00:15:21 +0800 Subject: [PATCH 3/4] =?UTF-8?q?feat(models):=20=E6=B7=BB=E5=8A=A0=E6=A8=A1?= =?UTF-8?q?=E5=9E=8B=E9=80=89=E6=8B=A9=E5=91=BD=E4=BB=A4=EF=BC=8C=E6=94=AF?= =?UTF-8?q?=E6=8C=81=E4=BB=8E=E9=85=8D=E7=BD=AE=E4=B8=AD=E5=B1=95=E7=A4=BA?= =?UTF-8?q?=E5=8F=AF=E7=94=A8=E6=A8=A1=E5=9E=8B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- packages/core/src/runtime/prompt.md | 12 +- packages/ui/src/index.tsx | 1 + packages/ui/src/tui/App.tsx | 105 +++++++++++++++--- packages/ui/src/tui/commands.ts | 32 +++++- .../tui/components/input/SuggestionList.tsx | 2 +- .../src/tui/components/layout/InputPrompt.tsx | 68 +++++++++++- packages/ui/src/tui/constants.ts | 1 + packages/ui/src/tui/slash/index.ts | 3 +- packages/ui/src/tui/slash/models.ts | 10 ++ 9 files changed, 197 insertions(+), 37 deletions(-) create mode 100644 packages/ui/src/tui/slash/models.ts diff --git a/packages/core/src/runtime/prompt.md b/packages/core/src/runtime/prompt.md index 2e15243..42c4070 100644 --- a/packages/core/src/runtime/prompt.md +++ b/packages/core/src/runtime/prompt.md @@ -2,8 +2,6 @@ 你是 **MemoAgent**,由 mCell 设计开发,并在 BunJS Runtime 中运行的高级编码代理。你的目标是精确、安全且有帮助地解决用户的编码任务。 -你的核心工作方式是 **ReAct 循环**。 - # 核心特质 (Personality) - **简洁明了**:沟通高效,避免废话。 @@ -17,6 +15,7 @@ - **普通回复**:直接输出文本。 - **使用工具**:当需要执行操作时,只输出一个 **Markdown JSON 代码块**,不要输出任何额外文字。 - **最终回答**:当任务完成或需要回复用户时,直接输出您的回答(支持 Markdown),**不要**再调用工具。 +- **硬性格式约束**:调用工具的消息必须是“只含 JSON 代码块、无其它内容”的单独消息;禁止在同一条消息里出现 JSON 工具块与任何文本、提示、思考或解释(包括前后空行/注释/Markdown)。 ## 工具调用示例 @@ -32,7 +31,6 @@ 1. 一次回复依然建议只调用**一个**工具,然后等待结果。 2. 调用工具时,回复中只能包含 JSON 工具块,不能包含其它文本。 3. 只要您**不**输出 JSON 工具块,您的所有文本都会直接展示给用户作为最终回答。 -4. 如果需要先解释自己的思路,请单独发送一条自然语言消息;**当且仅当**已经确定要调用工具时,再单独回复 JSON 代码块。思考、状态提示与 JSON 工具调用绝不能出现在同一条消息中。 # 行为准则与设定 (Guidelines) @@ -109,11 +107,3 @@ - Add: `{"type": "add", "todos": [{"content": "string", "status": "pending"}]}` - Update: `{"type": "update", "todos": [{"id": "string", "content": "string", "status": "completed"}]}` - Remove: `{"type": "remove", "ids": ["string"]}` - -# 启动确认 - -现在,等待用户输入。一旦收到任务: - -1. 分析需求。 -2. (如果任务复杂) 使用 `todo` 建立计划。 -3. 开始工作。 diff --git a/packages/ui/src/index.tsx b/packages/ui/src/index.tsx index f052172..cbb55ec 100644 --- a/packages/ui/src/index.tsx +++ b/packages/ui/src/index.tsx @@ -182,6 +182,7 @@ async function runInteractiveTui(parsed: ParsedArgs) { mcpServerNames={Object.keys(loaded.config.mcp_servers ?? {})} cwd={process.cwd()} sessionsDir={sessionsDir} + providers={loaded.config.providers} />, { exitOnCtrlC: false }, ) diff --git a/packages/ui/src/tui/App.tsx b/packages/ui/src/tui/App.tsx index 3fd6a1d..53d2dff 100644 --- a/packages/ui/src/tui/App.tsx +++ b/packages/ui/src/tui/App.tsx @@ -1,13 +1,17 @@ import { useCallback, useEffect, useMemo, useRef, useState } from 'react' import { readFile } from 'node:fs/promises' +import { randomUUID } from 'node:crypto' import { Box, useApp } from 'ink' import { createAgentSession, + loadMemoConfig, + writeMemoConfig, type AgentSession, type AgentSessionDeps, type AgentSessionOptions, type ChatMessage, type InputHistoryEntry, + type ProviderConfig, } from '@memo/core' import type { StepView, SystemMessage, TurnView } from './types' import { HeaderBar } from './components/layout/HeaderBar' @@ -25,6 +29,7 @@ export type AppProps = { mcpServerNames: string[] cwd: string sessionsDir: string + providers: ProviderConfig[] } function createEmptyTurn(index: number): TurnView { @@ -39,8 +44,15 @@ export function App({ mcpServerNames, cwd, sessionsDir, + providers, }: AppProps) { const { exit } = useApp() + const [currentProvider, setCurrentProvider] = useState(providerName) + const [currentModel, setCurrentModel] = useState(model) + const [sessionOptionsState, setSessionOptionsState] = useState({ + ...sessionOptions, + providerName, + }) const [session, setSession] = useState(null) const [turns, setTurns] = useState([]) const [systemMessages, setSystemMessages] = useState([]) @@ -51,6 +63,7 @@ export function App({ const [sessionLogPath, setSessionLogPath] = useState(null) const [historicalTurns, setHistoricalTurns] = useState([]) const [pendingHistoryMessages, setPendingHistoryMessages] = useState(null) + const sessionRef = useRef(null) const appendSystemMessage = useCallback((title: string, content: string) => { const id = `${Date.now()}-${Math.random().toString(16).slice(2)}` @@ -156,35 +169,40 @@ export function App({ ) useEffect(() => { - let active = true + let cancelled = false ;(async () => { - const created = await createAgentSession(deps, sessionOptions) - if (!active) { + const prev = sessionRef.current + if (prev) { + await prev.close() + } + const created = await createAgentSession(deps, sessionOptionsState) + if (cancelled) { await created.close() return } + sessionRef.current = created setSession(created) setSessionLogPath(created.historyFilePath ?? null) })() return () => { - active = false + cancelled = true } - }, [deps, sessionOptions]) + }, [deps, sessionOptionsState]) useEffect(() => { return () => { - if (session) { - void session.close() + if (sessionRef.current) { + void sessionRef.current.close() } } - }, [session]) + }, []) const handleExit = useCallback(async () => { - if (session) { - await session.close() + if (sessionRef.current) { + await sessionRef.current.close() } exit() - }, [exit, session]) + }, [exit]) const handleClear = useCallback(() => { setTurns([]) @@ -217,18 +235,62 @@ export function App({ [appendSystemMessage], ) + const persistCurrentProvider = useCallback( + async (name: string) => { + try { + const loaded = await loadMemoConfig() + const nextConfig = { ...loaded.config, current_provider: name } + await writeMemoConfig(loaded.configPath, nextConfig) + } catch (err) { + appendSystemMessage('配置保存失败', `未能保存模型选择: ${(err as Error).message}`) + } + }, + [appendSystemMessage], + ) + const handleCancelRun = useCallback(() => { if (!busy) return session?.cancelCurrentTurn?.() }, [busy, session]) + const handleModelSelect = useCallback( + async (provider: ProviderConfig) => { + if (provider.name === currentProvider && provider.model === currentModel) { + appendSystemMessage('模型切换', `已在使用 ${provider.name} (${provider.model})`) + return + } + if (busy) { + appendSystemMessage('模型切换', '当前正在运行,按 Esc Esc 取消后再切换模型。') + return + } + setStatusMessage(null) + setTurns([]) + setHistoricalTurns([]) + setPendingHistoryMessages(null) + currentTurnRef.current = null + setSession(null) + setSessionLogPath(null) + setCurrentProvider(provider.name) + setCurrentModel(provider.model) + setSessionOptionsState((prev) => ({ + ...prev, + sessionId: randomUUID(), + providerName: provider.name, + })) + await persistCurrentProvider(provider.name) + appendSystemMessage('模型切换', `已切换到 ${provider.name} (${provider.model})`) + }, + [appendSystemMessage, busy, currentModel, currentProvider, persistCurrentProvider], + ) + const handleCommand = useCallback( async (raw: string) => { const result = resolveSlashCommand(raw, { configPath, - providerName, - model, + providerName: currentProvider, + model: currentModel, mcpServerNames, + providers, }) if (result.kind === 'exit') { await handleExit() @@ -238,6 +300,10 @@ export function App({ handleClear() return } + if (result.kind === 'switch_model') { + await handleModelSelect(result.provider) + return + } appendSystemMessage(result.title, result.content) }, [ @@ -245,9 +311,11 @@ export function App({ configPath, handleClear, handleExit, + handleModelSelect, mcpServerNames, - model, - providerName, + currentModel, + currentProvider, + providers, ], ) @@ -276,7 +344,8 @@ export function App({ ) const lastTurn = turns[turns.length - 1] - const statusLine = statusMessage ?? (!session ? 'Initializing...' : busy ? 'Running' : 'Ready') + const statusLine = + statusMessage ?? (!session ? 'Initializing...' : busy ? 'Running' : 'Ready') const statusKind = statusMessage !== null ? 'error' : !session ? 'initializing' : busy ? 'running' : 'ready' const tokenLine = formatTokenUsage(lastTurn?.tokenUsage) @@ -285,7 +354,7 @@ export function App({ return ( - + diff --git a/packages/ui/src/tui/commands.ts b/packages/ui/src/tui/commands.ts index 8564456..0803839 100644 --- a/packages/ui/src/tui/commands.ts +++ b/packages/ui/src/tui/commands.ts @@ -1,17 +1,21 @@ +import type { ProviderConfig } from '@memo/core/config/config' + export type SlashResolveContext = { configPath: string providerName: string model: string mcpServerNames: string[] + providers: ProviderConfig[] } export type SlashCommandResult = | { kind: 'exit' } | { kind: 'clear' } | { kind: 'message'; title: string; content: string } + | { kind: 'switch_model'; provider: ProviderConfig } export function resolveSlashCommand(raw: string, context: SlashResolveContext): SlashCommandResult { - const [command] = raw.trim().slice(1).split(/\s+/) + const [command, ...rest] = raw.trim().slice(1).split(/\s+/) switch (command) { case 'exit': return { kind: 'exit' } @@ -23,6 +27,32 @@ export function resolveSlashCommand(raw: string, context: SlashResolveContext): title: 'history', content: '输入 "history" 能够按当前工作目录筛选历史记录并选择回填。', } + case 'models': { + if (!context.providers.length) { + return { + kind: 'message', + title: 'models', + content: `当前无可用模型,请检查 ${context.configPath} 的 providers 配置。`, + } + } + const query = rest.join(' ').trim() + const found = + context.providers.find((p) => p.name === query) ?? + context.providers.find((p) => p.model === query) + if (found) { + return { kind: 'switch_model', provider: found } + } + const lines = context.providers.map((p) => { + const baseUrl = p.base_url ? ` @ ${p.base_url}` : '' + return `- ${p.name}: ${p.model}${baseUrl}` + }) + const hint = query ? `未找到 ${query},` : '' + return { + kind: 'message', + title: 'models', + content: `${hint}可用模型:\n${lines.join('\n')}`, + } + } default: return { kind: 'message', title: 'unknown', content: `Unknown command: ${raw}` } } diff --git a/packages/ui/src/tui/components/input/SuggestionList.tsx b/packages/ui/src/tui/components/input/SuggestionList.tsx index 05d66e5..eceba74 100644 --- a/packages/ui/src/tui/components/input/SuggestionList.tsx +++ b/packages/ui/src/tui/components/input/SuggestionList.tsx @@ -4,7 +4,7 @@ export type SuggestionListItem = { id: string title: string subtitle?: string - kind: 'file' | 'history' | 'slash' + kind: 'file' | 'history' | 'slash' | 'model' badge?: string } diff --git a/packages/ui/src/tui/components/layout/InputPrompt.tsx b/packages/ui/src/tui/components/layout/InputPrompt.tsx index 977a77f..d566ebb 100644 --- a/packages/ui/src/tui/components/layout/InputPrompt.tsx +++ b/packages/ui/src/tui/components/layout/InputPrompt.tsx @@ -2,7 +2,12 @@ import { useCallback, useEffect, useMemo, useRef, useState } from 'react' import { readFile, readdir, stat } from 'node:fs/promises' import { basename, join, resolve } from 'node:path' import { Box, Text, useInput, useStdout } from 'ink' -import { getFileSuggestions, getSessionLogDir, type InputHistoryEntry } from '@memo/core' +import { + getFileSuggestions, + getSessionLogDir, + type InputHistoryEntry, + type ProviderConfig, +} from '@memo/core' import { USER_PREFIX } from '../../constants' import { buildPaddedLine } from '../../utils' import { SuggestionList, type SuggestionListItem } from '../input/SuggestionList' @@ -17,23 +22,31 @@ type InputPromptProps = { onClear: () => void onCancelRun: () => void onHistorySelect?: (entry: InputHistoryEntry) => void + onModelSelect?: (provider: ProviderConfig) => void history: string[] cwd: string sessionsDir: string currentSessionFile?: string + providers: ProviderConfig[] } -type SuggestionMode = 'none' | 'file' | 'history' | 'slash' +type SuggestionMode = 'none' | 'file' | 'history' | 'slash' | 'model' type SuggestionItem = SuggestionListItem & { value: string - meta?: { isDir?: boolean; slashCommand?: SlashCommand; historyEntry?: InputHistoryEntry } + meta?: { + isDir?: boolean + slashCommand?: SlashCommand + historyEntry?: InputHistoryEntry + provider?: ProviderConfig + } } type FileTrigger = { type: 'file'; query: string; tokenStart: number } type HistoryTrigger = { type: 'history'; keyword: string } type SlashTrigger = { type: 'slash'; keyword: string } -type SuggestionTrigger = FileTrigger | HistoryTrigger | SlashTrigger +type ModelsTrigger = { type: 'models'; keyword: string } +type SuggestionTrigger = FileTrigger | HistoryTrigger | SlashTrigger | ModelsTrigger export function InputPrompt({ disabled, @@ -41,11 +54,13 @@ export function InputPrompt({ onExit, onClear, onCancelRun, + onModelSelect, history, cwd, sessionsDir, currentSessionFile, onHistorySelect, + providers, }: InputPromptProps) { const { stdout } = useStdout() const [value, setValue] = useState('') @@ -140,6 +155,29 @@ export function InputPrompt({ ) return } + if (trigger.type === 'models') { + const keyword = trigger.keyword.toLowerCase() + const filtered = (providers ?? []).filter((p) => { + const name = p.name?.toLowerCase() ?? '' + const model = p.model?.toLowerCase() ?? '' + if (!keyword) return true + return name.includes(keyword) || model.includes(keyword) + }) + const mapped = filtered.map((provider) => ({ + id: provider.name, + title: `${provider.name}: ${provider.model}`, + subtitle: provider.base_url ?? provider.env_api_key ?? '', + kind: 'model' as const, + value: `/models ${provider.name}`, + meta: { provider }, + })) + setSuggestionMode('model') + setSuggestionItems(mapped) + setActiveIndex((prev) => + mapped.length ? Math.min(prev, mapped.length - 1) : 0, + ) + return + } if (trigger.type === 'slash') { const keyword = trigger.keyword.toLowerCase() const filtered = keyword @@ -175,7 +213,7 @@ export function InputPrompt({ return () => { cancelled = true } - }, [trigger, cwd, sessionsDir, currentSessionFile]) + }, [trigger, cwd, sessionsDir, currentSessionFile, providers]) const applySuggestion = useCallback( (item?: SuggestionItem) => { @@ -220,8 +258,16 @@ export function InputPrompt({ }) return } + if (suggestionMode === 'model' && item.meta?.provider) { + void onModelSelect?.(item.meta.provider) + setValue('') + setHistoryIndex(null) + setDraft('') + closeSuggestions() + return + } }, - [closeSuggestions, onClear, onExit, suggestionMode, trigger, value], + [closeSuggestions, onClear, onExit, onModelSelect, suggestionMode, trigger, value], ) useInput((input, key) => { @@ -473,6 +519,8 @@ function formatSessionFileName(filePath: string) { } function detectSuggestionTrigger(value: string): SuggestionTrigger | null { + const modelsTrigger = detectModelsTrigger(value) + if (modelsTrigger) return modelsTrigger const slashTrigger = detectSlashTrigger(value) if (slashTrigger) return slashTrigger const fileTrigger = detectFileTrigger(value) @@ -517,6 +565,14 @@ function detectHistoryTrigger(value: string): HistoryTrigger | null { } } +function detectModelsTrigger(value: string): ModelsTrigger | null { + const trimmedStart = value.trimStart() + if (!trimmedStart.startsWith('/models')) return null + const rest = trimmedStart.slice('/models'.length) + if (rest && !rest.startsWith(' ')) return null + return { type: 'models', keyword: rest.trim() } +} + function detectSlashTrigger(value: string): SlashTrigger | null { const trimmedStart = value.trimStart() if (!trimmedStart.startsWith('/')) return null diff --git a/packages/ui/src/tui/constants.ts b/packages/ui/src/tui/constants.ts index 4688e29..a29f436 100644 --- a/packages/ui/src/tui/constants.ts +++ b/packages/ui/src/tui/constants.ts @@ -4,6 +4,7 @@ export const HELP_TEXT = [ ' /exit Exit the session', ' /clear Clear the screen', ' /tools List tools', + ' /models Pick a model from config', ' /config Show config and provider info', '', 'Shortcuts:', diff --git a/packages/ui/src/tui/slash/index.ts b/packages/ui/src/tui/slash/index.ts index 0cf6c9c..a4dc0e8 100644 --- a/packages/ui/src/tui/slash/index.ts +++ b/packages/ui/src/tui/slash/index.ts @@ -2,7 +2,8 @@ import type { SlashCommand } from './types' import { clearCommand } from './clear' import { exitCommand } from './exit' import { historyCommand } from './history' +import { modelsCommand } from './models' -export const SLASH_COMMANDS: SlashCommand[] = [exitCommand, clearCommand, historyCommand] +export const SLASH_COMMANDS: SlashCommand[] = [exitCommand, clearCommand, historyCommand, modelsCommand] export type { SlashCommand, SlashCommandContext } from './types' diff --git a/packages/ui/src/tui/slash/models.ts b/packages/ui/src/tui/slash/models.ts new file mode 100644 index 0000000..97434a4 --- /dev/null +++ b/packages/ui/src/tui/slash/models.ts @@ -0,0 +1,10 @@ +import type { SlashCommand } from './types' + +export const modelsCommand: SlashCommand = { + name: 'models', + description: '选择模型(展示配置里的 providers)', + run: ({ closeSuggestions, setInputValue }) => { + closeSuggestions(false) + setInputValue('/models ') + }, +} From 4c0b3c2bcaa42e2632572c118ccacc5cd4fc6696 Mon Sep 17 00:00:00 2001 From: minorcell Date: Sat, 27 Dec 2025 00:25:04 +0800 Subject: [PATCH 4/4] =?UTF-8?q?feat(app):=20=E4=BC=98=E5=8C=96=E5=8E=86?= =?UTF-8?q?=E5=8F=B2=E8=AE=B0=E5=BD=95=E5=8A=A0=E8=BD=BD=E9=80=BB=E8=BE=91?= =?UTF-8?q?=EF=BC=8C=E7=A1=AE=E4=BF=9D=E4=BC=9A=E8=AF=9D=E4=B8=8A=E4=B8=8B?= =?UTF-8?q?=E6=96=87=E6=AD=A3=E7=A1=AE=E6=9B=B4=E6=96=B0=EF=BC=9B=E8=B0=83?= =?UTF-8?q?=E6=95=B4=E6=AD=A5=E9=AA=A4=E8=A7=86=E5=9B=BE=E7=BB=84=E4=BB=B6?= =?UTF-8?q?=E7=9A=84=E5=B8=83=E5=B1=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- packages/ui/src/tui/App.tsx | 22 ++++++++++++++----- .../ui/src/tui/components/turns/StepView.tsx | 2 +- 2 files changed, 18 insertions(+), 6 deletions(-) diff --git a/packages/ui/src/tui/App.tsx b/packages/ui/src/tui/App.tsx index 53d2dff..35b3ca3 100644 --- a/packages/ui/src/tui/App.tsx +++ b/packages/ui/src/tui/App.tsx @@ -223,7 +223,14 @@ export function App({ const parsed = parseHistoryLog(raw) setHistoricalTurns(parsed.turns) setPendingHistoryMessages(parsed.messages) + setBusy(false) + setStatusMessage(null) setTurns([]) + setSession(null) + setSessionLogPath(null) + currentTurnRef.current = null + // 重新拉起 session,避免旧上下文残留/计数错位 + setSessionOptionsState((prev) => ({ ...prev, sessionId: randomUUID() })) appendSystemMessage('历史记录已加载', parsed.summary || entry.input) } catch (err) { appendSystemMessage( @@ -319,6 +326,15 @@ export function App({ ], ) + useEffect(() => { + if (!session || !pendingHistoryMessages?.length) return + // 用历史对话覆盖当前 session 的用户上下文,保留系统提示词。 + const systemMessage = session.history[0] + if (!systemMessage) return + session.history.splice(0, session.history.length, systemMessage, ...pendingHistoryMessages) + setPendingHistoryMessages(null) + }, [pendingHistoryMessages, session]) + const handleSubmit = useCallback( async (value: string) => { if (!session || busy) return @@ -328,10 +344,6 @@ export function App({ return } setInputHistory((prev) => [...prev, value]) - if (pendingHistoryMessages?.length && session) { - session.history.push(...pendingHistoryMessages) - setPendingHistoryMessages(null) - } setBusy(true) try { await session.runTurn(value) @@ -340,7 +352,7 @@ export function App({ setBusy(false) } }, - [busy, handleCommand, session, pendingHistoryMessages], + [busy, handleCommand, session], ) const lastTurn = turns[turns.length - 1] diff --git a/packages/ui/src/tui/components/turns/StepView.tsx b/packages/ui/src/tui/components/turns/StepView.tsx index 711d8d7..684adc7 100644 --- a/packages/ui/src/tui/components/turns/StepView.tsx +++ b/packages/ui/src/tui/components/turns/StepView.tsx @@ -16,7 +16,7 @@ export function StepView({ step }: StepViewProps) { {shouldRenderAssistant ? : null} {step.action ? ( - + Tool: {step.action.tool} [{step.toolStatus ?? 'pending'}]