diff --git a/cli/src/App.tsx b/cli/src/App.tsx index 680f6fd..9ed673a 100644 --- a/cli/src/App.tsx +++ b/cli/src/App.tsx @@ -89,6 +89,7 @@ export default function App() { const [workingTextTick, setWorkingTextTick] = useState(0); const [workingDotTick, setWorkingDotTick] = useState(0); const [timelineDotTick, setTimelineDotTick] = useState(0); + const [layoutRefreshTick, setLayoutRefreshTick] = useState(0); const [models, setModels] = useState([]); const [skills, setSkills] = useState([]); const [permissionOptions, setPermissionOptions] = useState([]); @@ -97,6 +98,8 @@ export default function App() { const commandMatches = useMemo(() => filterCommands(input), [input]); const runningSeconds = runningStartedAt ? Math.max(0, Math.floor((nowMs - runningStartedAt) / 1000)) : 0; const hasRunningTimeline = useMemo(() => timeline.some((item) => item.status === 'running'), [timeline]); + const slashMenuVisibleCount = mode === 'slash_menu' ? Math.max(1, Math.min(5, commandMatches.length)) : 0; + const composerCursorYOffset = mode === 'slash_menu' ? 1 + slashMenuVisibleCount : 0; useEffect(() => { if (!running) return; @@ -145,6 +148,13 @@ export default function App() { .catch((err) => addItem({ kind: 'tool', title: '初始化失败', status: 'failed', body: String(err.message || err) })); }, [mode]); + useEffect(() => { + const timer = setTimeout(() => { + setLayoutRefreshTick((v) => v + 1); + }, 0); + return () => clearTimeout(timer); + }, [mode, commandMatches.length, running, input.length, timeline.length]); + function addItem(item: Omit) { setTimeline((prev) => [...prev, { id: id(), ...item }]); } @@ -505,6 +515,29 @@ export default function App() { void handleInput(chunk, key); }); + function handleComposerChange(next: string) { + setInput(next); + if (next.startsWith('/')) { + if (mode !== 'slash_menu') setMode('slash_menu'); + setSlashIndex(0); + } else if (mode === 'slash_menu') { + setMode('composer'); + setSlashIndex(0); + } + } + + async function handleComposerSubmit(raw: string) { + const text = String(raw || '').trim(); + if (!text) return; + if (text.startsWith('/')) { + const command = filterCommands(text)[0]; + if (command) await executeCommand(command); + return; + } + setInput(''); + await submitUserMessage(text); + } + async function handleInput(chunk: string, key: any) { if (mode === 'workspace_prompt') { if (key.upArrow || chunk === 'k') setWorkspacePromptIndex(0); @@ -575,21 +608,12 @@ export default function App() { else if (key.return) { const command = commandMatches[slashIndex]; if (command) await executeCommand(command); - } else if (key.backspace || key.delete) { - setInput((prev) => { - const next = Array.from(prev).slice(0, -1).join(''); - if (!next) setMode('composer'); - return next; - }); - setSlashIndex(0); - } else if (chunk && !key.ctrl && !key.meta) { - const normalized = chunk.replace(/\r\n/g, '\n').replace(/\r/g, '\n'); - setInput((prev) => prev + normalized); - setSlashIndex(0); } return; } + if (mode === 'composer') return; + if (key.return) { const text = input.trim(); if (!text) return; @@ -646,7 +670,7 @@ export default function App() { {mode === 'status_panel' ? : null} {mode === 'help_panel' ? : null} {mode === 'approval' ? : null} - {mode === 'composer' || mode === 'slash_menu' ? <> : null} + {mode === 'composer' || mode === 'slash_menu' ? <> : null} diff --git a/cli/src/ImeTextInput.tsx b/cli/src/ImeTextInput.tsx new file mode 100644 index 0000000..88c3552 --- /dev/null +++ b/cli/src/ImeTextInput.tsx @@ -0,0 +1,134 @@ +import React from 'react'; +import { Text, useCursor, useInput, useStdout, type DOMElement } from 'ink'; + +type Props = { + value: string; + disabled?: boolean; + contentRef: React.RefObject; + cursorYOffset?: number; + refreshTick?: number; + onChange: (value: string) => void; + onSubmit: (value: string) => void | Promise; +}; + +export default function ImeTextInput({ value, disabled, contentRef, cursorYOffset = 0, refreshTick = 0, onChange, onSubmit }: Props) { + const { stdout } = useStdout(); + const { setCursorPosition } = useCursor(); + const cursorOffsetRef = React.useRef(Array.from(value || '').length); + const [, forceRender] = React.useReducer((x: number) => x + 1, 0); + const pendingYOffsetRef = React.useRef(0); + const seenRefreshTickRef = React.useRef(refreshTick); + + React.useEffect(() => { + const len = Array.from(value || '').length; + if (cursorOffsetRef.current > len) cursorOffsetRef.current = len; + }, [value]); + + React.useEffect(() => { + if (refreshTick !== seenRefreshTickRef.current) { + seenRefreshTickRef.current = refreshTick; + pendingYOffsetRef.current = cursorYOffset; + forceRender(); + } + }, [refreshTick, cursorYOffset]); + + useInput((input, key) => { + if (disabled) return; + if (key.return) { + void onSubmit(value); + return; + } + if (key.leftArrow) { + const next = Math.max(0, cursorOffsetRef.current - 1); + if (next !== cursorOffsetRef.current) { + cursorOffsetRef.current = next; + forceRender(); + } + return; + } + if (key.rightArrow) { + const len = Array.from(value || '').length; + const next = Math.min(len, cursorOffsetRef.current + 1); + if (next !== cursorOffsetRef.current) { + cursorOffsetRef.current = next; + forceRender(); + } + return; + } + if (key.backspace || key.delete) { + if (cursorOffsetRef.current <= 0) return; + const chars = Array.from(value || ''); + chars.splice(cursorOffsetRef.current - 1, 1); + onChange(chars.join('')); + cursorOffsetRef.current = Math.max(0, cursorOffsetRef.current - 1); + return; + } + if (!input || key.ctrl || key.meta) return; + const chars = Array.from(value || ''); + chars.splice(cursorOffsetRef.current, 0, input); + onChange(chars.join('')); + cursorOffsetRef.current += Array.from(input).length; + }, { isActive: !disabled }); + + const origin = getAbsoluteOrigin(contentRef.current); + if (origin) { + const head = Array.from(value || '').slice(0, cursorOffsetRef.current).join(''); + const terminalRows = Math.max(1, stdout.rows || 24); + const bottomCompensation = origin.y >= terminalRows - 2 ? 1 : 0; + const yOffsetThisFrame = pendingYOffsetRef.current; + pendingYOffsetRef.current = 0; + setCursorPosition({ + x: origin.x + 2 + displayWidth(head), + y: origin.y + yOffsetThisFrame + bottomCompensation, + }); + } else { + setCursorPosition(undefined); + } + + return {value}; +} + +function getAbsoluteOrigin(node: DOMElement | null): { x: number; y: number } | null { + if (!node?.yogaNode) return null; + let x = 0; + let y = 0; + let current: DOMElement | undefined = node; + while (current?.parentNode) { + if (!current.yogaNode) return null; + x += current.yogaNode.getComputedLeft(); + y += current.yogaNode.getComputedTop(); + current = current.parentNode; + } + return { x, y }; +} + +function displayWidth(text: string): number { + let width = 0; + for (const char of Array.from(text)) { + const code = char.codePointAt(0) || 0; + if (code === 0) continue; + if (code < 32 || (code >= 0x7f && code < 0xa0)) continue; + width += isWideCodePoint(code) ? 2 : 1; + } + return width; +} + +function isWideCodePoint(code: number): boolean { + return ( + code >= 0x1100 && ( + code <= 0x115f || + code === 0x2329 || + code === 0x232a || + (code >= 0x2e80 && code <= 0xa4cf && code !== 0x303f) || + (code >= 0xac00 && code <= 0xd7a3) || + (code >= 0xf900 && code <= 0xfaff) || + (code >= 0xfe10 && code <= 0xfe19) || + (code >= 0xfe30 && code <= 0xfe6f) || + (code >= 0xff00 && code <= 0xff60) || + (code >= 0xffe0 && code <= 0xffe6) || + (code >= 0x1f300 && code <= 0x1f64f) || + (code >= 0x1f900 && code <= 0x1f9ff) || + (code >= 0x20000 && code <= 0x3fffd) + ) + ); +} diff --git a/cli/src/components.tsx b/cli/src/components.tsx index bb7ab6c..84adc61 100644 --- a/cli/src/components.tsx +++ b/cli/src/components.tsx @@ -1,5 +1,6 @@ -import React, { useEffect, useRef, useState } from 'react'; -import { Box, Text, useCursor, useStdout, type DOMElement } from 'ink'; +import React from 'react'; +import { Box, Text, useStdout, type DOMElement } from 'ink'; +import ImeTextInput from './ImeTextInput.js'; import type { CliStatus, SlashCommand, SkillDefinition, TimelineItem, Workspace } from './types.js'; import { formatContext, shortPath } from './format.js'; @@ -21,57 +22,47 @@ export function StatusLine({ status }: { status: CliStatus }) { ); } -export function Composer({ value, disabled, marginTop = 1 }: { value: string; disabled?: boolean; marginTop?: number }) { - const { stdout } = useStdout(); - const { setCursorPosition } = useCursor(); - const contentRef = useRef(null); - const [contentOrigin, setContentOrigin] = useState<{ x: number; y: number } | null>(null); - const lines = String(value || '').split(/\r?\n/); - const lastLine = lines[lines.length - 1] || ''; - const fallbackOrigin = { x: 1, y: Math.max(0, (stdout.rows || 24) - 3) }; - const origin = contentOrigin || fallbackOrigin; - setCursorPosition({ - x: origin.x + 2 + displayWidth(lastLine), - y: origin.y + Math.max(0, lines.length - 1), - }); - - useEffect(() => { - const next = getAbsoluteOrigin(contentRef.current); - if (!next) return; - setContentOrigin((prev) => (prev && prev.x === next.x && prev.y === next.y ? prev : next)); - }); +export function Composer( + { + value, + disabled, + marginTop = 1, + cursorYOffset = 0, + refreshTick = 0, + onChange, + onSubmit, + }: { + value: string; + disabled?: boolean; + marginTop?: number; + cursorYOffset?: number; + refreshTick?: number; + onChange: (value: string) => void; + onSubmit: (value: string) => void | Promise; + }, +) { + const contentRef = React.useRef(null); return ( - - {lines.map((line, index) => ( - - {index === 0 ? : } - {line} - {index === lines.length - 1 ? : null} - - ))} + + + ); } -function getAbsoluteOrigin(node: DOMElement | null): { x: number; y: number } | null { - if (!node?.yogaNode) return null; - let x = 0; - let y = 0; - let current: DOMElement | undefined = node; - while (current?.parentNode) { - if (!current.yogaNode) return null; - x += current.yogaNode.getComputedLeft(); - y += current.yogaNode.getComputedTop(); - current = current.parentNode; - } - return { x, y }; -} - function displayWidth(text: string): number { let width = 0; for (const char of Array.from(text)) { @@ -218,6 +209,7 @@ function wrapIndentedText(text: string, indent: number, width: number): string[] export function SlashMenu({ query, commands, selectedIndex }: { query: string; commands: SlashCommand[]; selectedIndex: number }) { const visibleCount = 5; + const leftIndent = ' '; const start = Math.max(0, selectedIndex - 2); const end = Math.min(commands.length, start + visibleCount); const adjustedStart = Math.max(0, end - visibleCount); @@ -229,11 +221,11 @@ export function SlashMenu({ query, commands, selectedIndex }: { query: string; c const index = adjustedStart + visibleIndex; return ( - {index === selectedIndex ? '› ' : ' '}{command.name.padEnd(13)} {command.description} + {leftIndent}{index === selectedIndex ? '› ' : ' '}{command.name.padEnd(13)} {command.description} ); })} - {!commands.length ? 无匹配命令 : null} + {!commands.length ? {leftIndent} 无匹配命令 : null} ); diff --git a/config/auto_approval.json b/config/auto_approval.json index 85735cb..ebb0657 100644 --- a/config/auto_approval.json +++ b/config/auto_approval.json @@ -1,6 +1,6 @@ { "name": "auto-approval-agent", - "url": "https://api.deepseek.com", + "url": "https://opencode.ai/zen/go/v1", "key": "${API_KEY_DEEPSEEK}", "model": "deepseek-v4-flash", "extra_params": { diff --git a/modules/approval_agent.py b/modules/approval_agent.py index 89e1879..bbff24c 100644 --- a/modules/approval_agent.py +++ b/modules/approval_agent.py @@ -114,6 +114,8 @@ class ApprovalAgent: row = { "created_at": int(time.time() * 1000), "messages": messages, + "trace": debug_transcript, + "final_result": final_result, } file = DEBUG_TRANSCRIPT_DIR / f"approval_{int(time.time() * 1000)}.json" file.write_text(json.dumps(row, ensure_ascii=False, indent=2), encoding="utf-8") diff --git a/static/src/composables/useLegacySocket.ts b/static/src/composables/useLegacySocket.ts index d297181..5ab577f 100644 --- a/static/src/composables/useLegacySocket.ts +++ b/static/src/composables/useLegacySocket.ts @@ -1674,7 +1674,7 @@ export async function initializeLegacySocket(ctx: any) { if (shouldRetry) { ctx.uiPushToast({ title: '即将重试', - message: `将在 ${retryIn} 秒后重试(第 ${retryAttempt}/${retryMax} 次)`, + message: `将在 ${retryIn} 秒后重试(第 ${retryAttempt}/${retryMax} 次)\n错误:${msg}`, type: 'info', duration: Math.max(retryIn, 1) * 1000 });