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:
parent
67fdb715f5
commit
16473c824d
@ -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<ModelDefinition[]>([]);
|
||||
const [skills, setSkills] = useState<SkillDefinition[]>([]);
|
||||
const [permissionOptions, setPermissionOptions] = useState<PermissionMode[]>([]);
|
||||
@ -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<TimelineItem, 'id'>) {
|
||||
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' ? <StatusPanel status={status} /> : null}
|
||||
{mode === 'help_panel' ? <HelpPanel commands={slashCommands} /> : 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} />
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
134
cli/src/ImeTextInput.tsx
Normal file
134
cli/src/ImeTextInput.tsx
Normal 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)
|
||||
)
|
||||
);
|
||||
}
|
||||
@ -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<DOMElement>(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<void>;
|
||||
},
|
||||
) {
|
||||
const contentRef = React.useRef<DOMElement>(null);
|
||||
|
||||
return (
|
||||
<Box flexDirection="column" marginTop={marginTop}>
|
||||
<Box borderStyle="single" borderColor={disabled ? 'gray' : 'cyan'} paddingX={1} flexDirection="column">
|
||||
<Box ref={contentRef} flexDirection="column">
|
||||
{lines.map((line, index) => (
|
||||
<Text key={`composer-line-${index}`}>
|
||||
{index === 0 ? <Text color="cyan">› </Text> : <Text> </Text>}
|
||||
<Text>{line}</Text>
|
||||
{index === lines.length - 1 ? <Text inverse> </Text> : null}
|
||||
</Text>
|
||||
))}
|
||||
<Box ref={contentRef}>
|
||||
<Text color="cyan">› </Text>
|
||||
<ImeTextInput
|
||||
value={value}
|
||||
disabled={disabled}
|
||||
contentRef={contentRef}
|
||||
cursorYOffset={cursorYOffset}
|
||||
refreshTick={refreshTick}
|
||||
onChange={onChange}
|
||||
onSubmit={onSubmit}
|
||||
/>
|
||||
</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 {
|
||||
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 (
|
||||
<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>
|
||||
);
|
||||
})}
|
||||
{!commands.length ? <Text color="gray"> 无匹配命令</Text> : null}
|
||||
{!commands.length ? <Text color="gray">{leftIndent} 无匹配命令</Text> : null}
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
|
||||
@ -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": {
|
||||
|
||||
@ -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")
|
||||
|
||||
@ -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
|
||||
});
|
||||
|
||||
Loading…
Reference in New Issue
Block a user