chore: 合并多项独立 WIP 改动

- cli: 增加 layoutRefreshTick 与 IME 输入组件,处理 slash_menu 光标偏移
- config/auto_approval: 切到 opencode 中转端点
- modules/approval_agent: 调试 transcript 增加 trace / final_result 字段
- static/useLegacySocket: 重试 toast 显示具体错误信息

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
JOJO 2026-05-23 01:58:55 +08:00
parent 67fdb715f5
commit 16473c824d
6 changed files with 211 additions and 59 deletions

View File

@ -89,6 +89,7 @@ export default function App() {
const [workingTextTick, setWorkingTextTick] = useState(0); const [workingTextTick, setWorkingTextTick] = useState(0);
const [workingDotTick, setWorkingDotTick] = useState(0); const [workingDotTick, setWorkingDotTick] = useState(0);
const [timelineDotTick, setTimelineDotTick] = useState(0); const [timelineDotTick, setTimelineDotTick] = useState(0);
const [layoutRefreshTick, setLayoutRefreshTick] = useState(0);
const [models, setModels] = useState<ModelDefinition[]>([]); const [models, setModels] = useState<ModelDefinition[]>([]);
const [skills, setSkills] = useState<SkillDefinition[]>([]); const [skills, setSkills] = useState<SkillDefinition[]>([]);
const [permissionOptions, setPermissionOptions] = useState<PermissionMode[]>([]); const [permissionOptions, setPermissionOptions] = useState<PermissionMode[]>([]);
@ -97,6 +98,8 @@ export default function App() {
const commandMatches = useMemo(() => filterCommands(input), [input]); const commandMatches = useMemo(() => filterCommands(input), [input]);
const runningSeconds = runningStartedAt ? Math.max(0, Math.floor((nowMs - runningStartedAt) / 1000)) : 0; const runningSeconds = runningStartedAt ? Math.max(0, Math.floor((nowMs - runningStartedAt) / 1000)) : 0;
const hasRunningTimeline = useMemo(() => timeline.some((item) => item.status === 'running'), [timeline]); 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(() => { useEffect(() => {
if (!running) return; if (!running) return;
@ -145,6 +148,13 @@ export default function App() {
.catch((err) => addItem({ kind: 'tool', title: '初始化失败', status: 'failed', body: String(err.message || err) })); .catch((err) => addItem({ kind: 'tool', title: '初始化失败', status: 'failed', body: String(err.message || err) }));
}, [mode]); }, [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<TimelineItem, 'id'>) { function addItem(item: Omit<TimelineItem, 'id'>) {
setTimeline((prev) => [...prev, { id: id(), ...item }]); setTimeline((prev) => [...prev, { id: id(), ...item }]);
} }
@ -505,6 +515,29 @@ export default function App() {
void handleInput(chunk, key); 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) { async function handleInput(chunk: string, key: any) {
if (mode === 'workspace_prompt') { if (mode === 'workspace_prompt') {
if (key.upArrow || chunk === 'k') setWorkspacePromptIndex(0); if (key.upArrow || chunk === 'k') setWorkspacePromptIndex(0);
@ -575,21 +608,12 @@ export default function App() {
else if (key.return) { else if (key.return) {
const command = commandMatches[slashIndex]; const command = commandMatches[slashIndex];
if (command) await executeCommand(command); 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; return;
} }
if (mode === 'composer') return;
if (key.return) { if (key.return) {
const text = input.trim(); const text = input.trim();
if (!text) return; if (!text) return;
@ -646,7 +670,7 @@ export default function App() {
{mode === 'status_panel' ? <StatusPanel status={status} /> : null} {mode === 'status_panel' ? <StatusPanel status={status} /> : null}
{mode === 'help_panel' ? <HelpPanel commands={slashCommands} /> : null} {mode === 'help_panel' ? <HelpPanel commands={slashCommands} /> : null}
{mode === 'approval' ? <Picker title={approvalTitle} items={approvalOptions} selectedIndex={approvalIndex} /> : null} {mode === 'approval' ? <Picker title={approvalTitle} items={approvalOptions} selectedIndex={approvalIndex} /> : null}
{mode === 'composer' || mode === 'slash_menu' ? <><SkillHint skill={status.activeSkill} /><Composer value={input} disabled={running} marginTop={0} /></> : null} {mode === 'composer' || mode === 'slash_menu' ? <><SkillHint skill={status.activeSkill} /><Composer value={input} disabled={running} marginTop={0} cursorYOffset={composerCursorYOffset} refreshTick={layoutRefreshTick} onChange={handleComposerChange} onSubmit={handleComposerSubmit} /></> : null}
<StatusLine status={status} /> <StatusLine status={status} />
</Box> </Box>
</Box> </Box>

134
cli/src/ImeTextInput.tsx Normal file
View File

@ -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<DOMElement | null>;
cursorYOffset?: number;
refreshTick?: number;
onChange: (value: string) => void;
onSubmit: (value: string) => void | Promise<void>;
};
export default function ImeTextInput({ value, disabled, contentRef, cursorYOffset = 0, refreshTick = 0, onChange, onSubmit }: Props) {
const { stdout } = useStdout();
const { setCursorPosition } = useCursor();
const cursorOffsetRef = React.useRef<number>(Array.from(value || '').length);
const [, forceRender] = React.useReducer((x: number) => x + 1, 0);
const pendingYOffsetRef = React.useRef<number>(0);
const seenRefreshTickRef = React.useRef<number>(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 <Text>{value}</Text>;
}
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)
)
);
}

View File

@ -1,5 +1,6 @@
import React, { useEffect, useRef, useState } from 'react'; import React from 'react';
import { Box, Text, useCursor, useStdout, type DOMElement } from 'ink'; import { Box, Text, useStdout, type DOMElement } from 'ink';
import ImeTextInput from './ImeTextInput.js';
import type { CliStatus, SlashCommand, SkillDefinition, TimelineItem, Workspace } from './types.js'; import type { CliStatus, SlashCommand, SkillDefinition, TimelineItem, Workspace } from './types.js';
import { formatContext, shortPath } from './format.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 }) { export function Composer(
const { stdout } = useStdout(); {
const { setCursorPosition } = useCursor(); value,
const contentRef = useRef<DOMElement>(null); disabled,
const [contentOrigin, setContentOrigin] = useState<{ x: number; y: number } | null>(null); marginTop = 1,
const lines = String(value || '').split(/\r?\n/); cursorYOffset = 0,
const lastLine = lines[lines.length - 1] || ''; refreshTick = 0,
const fallbackOrigin = { x: 1, y: Math.max(0, (stdout.rows || 24) - 3) }; onChange,
const origin = contentOrigin || fallbackOrigin; onSubmit,
setCursorPosition({ }: {
x: origin.x + 2 + displayWidth(lastLine), value: string;
y: origin.y + Math.max(0, lines.length - 1), disabled?: boolean;
}); marginTop?: number;
cursorYOffset?: number;
useEffect(() => { refreshTick?: number;
const next = getAbsoluteOrigin(contentRef.current); onChange: (value: string) => void;
if (!next) return; onSubmit: (value: string) => void | Promise<void>;
setContentOrigin((prev) => (prev && prev.x === next.x && prev.y === next.y ? prev : next)); },
}); ) {
const contentRef = React.useRef<DOMElement>(null);
return ( return (
<Box flexDirection="column" marginTop={marginTop}> <Box flexDirection="column" marginTop={marginTop}>
<Box borderStyle="single" borderColor={disabled ? 'gray' : 'cyan'} paddingX={1} flexDirection="column"> <Box borderStyle="single" borderColor={disabled ? 'gray' : 'cyan'} paddingX={1} flexDirection="column">
<Box ref={contentRef} flexDirection="column"> <Box ref={contentRef}>
{lines.map((line, index) => ( <Text color="cyan"> </Text>
<Text key={`composer-line-${index}`}> <ImeTextInput
{index === 0 ? <Text color="cyan"> </Text> : <Text> </Text>} value={value}
<Text>{line}</Text> disabled={disabled}
{index === lines.length - 1 ? <Text inverse> </Text> : null} contentRef={contentRef}
</Text> cursorYOffset={cursorYOffset}
))} refreshTick={refreshTick}
onChange={onChange}
onSubmit={onSubmit}
/>
</Box> </Box>
</Box> </Box>
</Box> </Box>
); );
} }
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 { function displayWidth(text: string): number {
let width = 0; let width = 0;
for (const char of Array.from(text)) { 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 }) { export function SlashMenu({ query, commands, selectedIndex }: { query: string; commands: SlashCommand[]; selectedIndex: number }) {
const visibleCount = 5; const visibleCount = 5;
const leftIndent = ' ';
const start = Math.max(0, selectedIndex - 2); const start = Math.max(0, selectedIndex - 2);
const end = Math.min(commands.length, start + visibleCount); const end = Math.min(commands.length, start + visibleCount);
const adjustedStart = Math.max(0, end - 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; const index = adjustedStart + visibleIndex;
return ( return (
<Text key={command.name} color={index === selectedIndex ? 'cyan' : undefined}> <Text key={command.name} color={index === selectedIndex ? 'cyan' : undefined}>
{index === selectedIndex ? ' ' : ' '}{command.name.padEnd(13)} {command.description} {leftIndent}{index === selectedIndex ? ' ' : ' '}{command.name.padEnd(13)} {command.description}
</Text> </Text>
); );
})} })}
{!commands.length ? <Text color="gray"> </Text> : null} {!commands.length ? <Text color="gray">{leftIndent} </Text> : null}
</Box> </Box>
</Box> </Box>
); );

View File

@ -1,6 +1,6 @@
{ {
"name": "auto-approval-agent", "name": "auto-approval-agent",
"url": "https://api.deepseek.com", "url": "https://opencode.ai/zen/go/v1",
"key": "${API_KEY_DEEPSEEK}", "key": "${API_KEY_DEEPSEEK}",
"model": "deepseek-v4-flash", "model": "deepseek-v4-flash",
"extra_params": { "extra_params": {

View File

@ -114,6 +114,8 @@ class ApprovalAgent:
row = { row = {
"created_at": int(time.time() * 1000), "created_at": int(time.time() * 1000),
"messages": messages, "messages": messages,
"trace": debug_transcript,
"final_result": final_result,
} }
file = DEBUG_TRANSCRIPT_DIR / f"approval_{int(time.time() * 1000)}.json" 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") file.write_text(json.dumps(row, ensure_ascii=False, indent=2), encoding="utf-8")

View File

@ -1674,7 +1674,7 @@ export async function initializeLegacySocket(ctx: any) {
if (shouldRetry) { if (shouldRetry) {
ctx.uiPushToast({ ctx.uiPushToast({
title: '即将重试', title: '即将重试',
message: `将在 ${retryIn} 秒后重试(第 ${retryAttempt}/${retryMax} 次)`, message: `将在 ${retryIn} 秒后重试(第 ${retryAttempt}/${retryMax} 次)\n错误${msg}`,
type: 'info', type: 'info',
duration: Math.max(retryIn, 1) * 1000 duration: Math.max(retryIn, 1) * 1000
}); });