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 [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
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 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>
|
||||||
);
|
);
|
||||||
|
|||||||
@ -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": {
|
||||||
|
|||||||
@ -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")
|
||||||
|
|||||||
@ -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
|
||||||
});
|
});
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user