Skip to content

Commit 47d3c21

Browse files
committed
feat(ui): 添加 /raw 模式支持及相关组件和逻辑
- 新增 RawMode 功能,包括 Normal、Lite 和 Raw scrollback 模式 - App 组件中集成 RawMode 上下文及切换逻辑,支持在 Raw 模式下直接向 stdout 渲染消息 - 增加 RawModeExitPrompt 组件,支持按 ESC 退出原始模式 - 新增 RawModelDropdown 组件,提供原始模式选择下拉菜单 - 在 PromptInput 中集成原始模式选择交互及状态管理 - 调整消息视图实现,拆分 MessageView 到 compoments 目录,支持根据 RawMode 呈现不同内容 - 新建 AppContainer 组件,包装 App 并提供版本上下文和 RawModeProvider - 修改 SlashCommand 体系,支持内置 /raw 命令及对应测试覆盖 - 更新 cli 入口,使用 AppContainer 替换直接渲染 App,传递版本信息 - 移除旧 MessageView 文件,重构消息渲染逻辑 - 优化 SlashCommandMenu 显示,支持命令参数提示显示 - 更新相关测试,支持原始模式功能验证
1 parent 75d68f4 commit 47d3c21

21 files changed

Lines changed: 750 additions & 417 deletions

src/cli.tsx

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,8 @@
11
import React from "react";
22
import { render } from "ink";
3-
import { App } from "./ui";
43
import { setShellIfWindows } from "./common/shell-utils";
54
import { checkForNpmUpdate, promptForPendingUpdate, type PackageInfo } from "./updateCheck";
5+
import AppContainer from "./ui/AppContainer";
66

77
const args = process.argv.slice(2);
88
const packageInfo = readPackageInfo();
@@ -81,7 +81,7 @@ async function main(): Promise<void> {
8181
const appInitialPrompt = initialPrompt;
8282
initialPrompt = undefined;
8383
const inkInstance = render(
84-
<App
84+
<AppContainer
8585
projectRoot={projectRoot}
8686
version={packageInfo.version}
8787
initialPrompt={appInitialPrompt}

src/tests/messageView.test.ts

Lines changed: 18 additions & 33 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,8 @@
11
import { test } from "node:test";
22
import assert from "node:assert/strict";
3-
import { MessageView, parseDiffPreview } from "../ui";
4-
import type { SessionMessage } from "../session";
3+
import { parseDiffPreview } from "../ui";
4+
import { buildThinkingSummary } from "../ui/compoments/MessageView/utils";
5+
import { RawMode } from "../ui/contexts";
56

67
test("parseDiffPreview removes headers and classifies lines", () => {
78
const lines = parseDiffPreview(
@@ -25,45 +26,29 @@ test("parseDiffPreview keeps nonstandard context lines", () => {
2526

2627
test("MessageView summarizes thinking content across lines", () => {
2728
assert.equal(
28-
getThinkingParams({
29-
content: "Plan:\n\nInspect the code and update tests",
30-
}),
29+
buildThinkingSummary("Plan:\n\nInspect the code and update tests", null, RawMode.Lite),
3130
"Plan: Inspect the code and update tests"
3231
);
3332
});
3433

35-
test("MessageView removes a trailing colon from thinking summaries", () => {
36-
assert.equal(getThinkingParams({ content: "Planning:" }), "Planning");
34+
test("MessageView removes a trailing colon from thinking summary", () => {
35+
assert.equal(buildThinkingSummary("Planning:", null, RawMode.Lite), "Planning");
3736
});
3837

39-
test("MessageView falls back to a reasoning placeholder for hidden reasoning content", () => {
38+
test("MessageView falls back to a reasoning placeholder for hidden reasoning content in Lite mode", () => {
4039
assert.equal(
41-
getThinkingParams({
42-
content: "",
43-
messageParams: { reasoning_content: "hidden chain of thought" },
44-
}),
40+
buildThinkingSummary("", { reasoning_content: "hidden chain of thought" }, RawMode.Lite),
4541
"(reasoning...)"
4642
);
4743
});
4844

49-
function getThinkingParams(overrides: Partial<SessionMessage>): string {
50-
const view = MessageView({ message: buildAssistantMessage(overrides) }) as any;
51-
return view.props.children.props.params;
52-
}
53-
54-
function buildAssistantMessage(overrides: Partial<SessionMessage>): SessionMessage {
55-
return {
56-
id: "message-1",
57-
sessionId: "session-1",
58-
role: "assistant",
59-
content: "",
60-
contentParams: null,
61-
messageParams: null,
62-
compacted: false,
63-
visible: true,
64-
createTime: "2026-01-01T00:00:00.000Z",
65-
updateTime: "2026-01-01T00:00:00.000Z",
66-
meta: { asThinking: true },
67-
...overrides,
68-
};
69-
}
45+
test("MessageView shows full reasoning content in Normal/Raw mode", () => {
46+
assert.equal(
47+
buildThinkingSummary("", { reasoning_content: "hidden chain of thought" }, RawMode.None),
48+
"hidden chain of thought"
49+
);
50+
assert.equal(
51+
buildThinkingSummary("", { reasoning_content: "hidden chain of thought" }, RawMode.Raw),
52+
"hidden chain of thought"
53+
);
54+
});

src/tests/slashCommands.test.ts

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,7 @@ test("buildSlashCommands prefixes skills before built-ins", () => {
1919
assert.equal(items[0].kind, "skill");
2020
assert.equal(items[0].name, "skill-writer");
2121
const builtinNames = items.filter((i) => i.kind !== "skill").map((i) => i.name);
22-
assert.deepEqual(builtinNames, ["skills", "model", "new", "init", "resume", "continue", "mcp", "exit"]);
22+
assert.deepEqual(builtinNames, ["skills", "model", "new", "init", "resume", "continue", "mcp", "raw", "exit"]);
2323
});
2424

2525
test("filterSlashCommands matches partial prefixes", () => {
@@ -80,6 +80,13 @@ test("findExactSlashCommand returns built-in /model", () => {
8080
assert.equal(item?.kind, "model");
8181
});
8282

83+
test("findExactSlashCommand returns built-in /raw", () => {
84+
const items = buildSlashCommands(skills);
85+
const item = findExactSlashCommand(items, "/raw");
86+
assert.ok(item);
87+
assert.equal(item?.kind, "raw");
88+
});
89+
8390
test("findExactSlashCommand returns the matching skill", () => {
8491
const items = buildSlashCommands(skills);
8592
const item = findExactSlashCommand(items, "/code-review");

src/ui/App.tsx

Lines changed: 59 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -6,24 +6,24 @@ import * as os from "os";
66
import * as path from "path";
77
import OpenAI from "openai";
88
import {
9-
SessionManager,
109
type LlmStreamProgress,
1110
type MessageMeta,
1211
type SessionEntry,
12+
SessionManager,
1313
type SessionMessage,
1414
type SessionStatus,
1515
type SkillInfo,
1616
type UserPromptContent,
1717
} from "../session";
1818
import {
1919
applyModelConfigSelection,
20-
resolveSettingsSources,
2120
type DeepcodingSettings,
2221
type ModelConfigSelection,
2322
type ResolvedDeepcodingSettings,
23+
resolveSettingsSources,
2424
} from "../settings";
2525
import { PromptInput, type PromptSubmission } from "./PromptInput";
26-
import { MessageView } from "./MessageView";
26+
import { MessageView, RawModeExitPrompt } from "./compoments";
2727
import { SessionList } from "./SessionList";
2828
import { buildLoadingText } from "./loadingText";
2929
import { findExpandedThinkingId } from "./thinkingState";
@@ -32,11 +32,13 @@ import { AskUserQuestionPrompt } from "./AskUserQuestionPrompt";
3232
import { McpStatusList } from "./McpStatusList";
3333
import { ProcessStdoutView } from "./ProcessStdoutView";
3434
import {
35+
type AskUserQuestionAnswers,
3536
findPendingAskUserQuestion,
3637
formatAskUserQuestionAnswers,
37-
type AskUserQuestionAnswers,
3838
} from "./askUserQuestion";
3939
import { buildExitSummaryText } from "./exitSummary";
40+
import { RawMode, useRawModeContext } from "./contexts";
41+
import { renderMessageToStdout } from "./compoments/MessageView/utils";
4042

4143
const DEFAULT_MODEL = "deepseek-v4-pro";
4244
const DEFAULT_BASE_URL = "https://api.deepseek.com";
@@ -45,12 +47,11 @@ type View = "chat" | "session-list" | "mcp-status";
4547

4648
type AppProps = {
4749
projectRoot: string;
48-
version?: string;
4950
initialPrompt?: string;
5051
onRestart?: () => void;
5152
};
5253

53-
export function App({ projectRoot, version = "", initialPrompt, onRestart }: AppProps): React.ReactElement {
54+
export function App({ projectRoot, initialPrompt, onRestart }: AppProps): React.ReactElement {
5455
const { exit } = useApp();
5556
const { stdout, write } = useStdout();
5657
const { columns } = useWindowSize();
@@ -75,6 +76,10 @@ export function App({ projectRoot, version = "", initialPrompt, onRestart }: App
7576
const [showProcessStdout, setShowProcessStdout] = useState(false);
7677
const processStdoutRef = useRef<Map<number, string>>(new Map());
7778

79+
const { mode, setMode } = useRawModeContext();
80+
const rawModeRef = useRef<RawMode>(mode);
81+
rawModeRef.current = mode;
82+
7883
const messagesRef = useRef<SessionMessage[]>([]);
7984
messagesRef.current = messages;
8085

@@ -86,6 +91,10 @@ export function App({ projectRoot, version = "", initialPrompt, onRestart }: App
8691
renderMarkdown: (text) => text,
8792
onAssistantMessage: (message: SessionMessage) => {
8893
setMessages((prev) => [...prev, message]);
94+
if (rawModeRef.current === RawMode.Raw) {
95+
process.stdout.write("\n");
96+
process.stdout.write(renderMessageToStdout(message, rawModeRef.current) + "\n\n");
97+
}
8998
},
9099
onSessionEntryUpdated: (entry) => {
91100
setStatusLine(buildStatusLine(entry));
@@ -362,6 +371,39 @@ export function App({ projectRoot, version = "", initialPrompt, onRestart }: App
362371
[sessionManager, refreshSkills]
363372
);
364373

374+
const handleRawModeChange = useCallback(
375+
(nextMode: string) => {
376+
const activeSessionId = sessionManager.getActiveSessionId();
377+
if (!activeSessionId) {
378+
return;
379+
}
380+
381+
setMode(nextMode as RawMode);
382+
383+
// Clear screen to remove stale formatted text.
384+
process.stdout.write("\u001B[2J\u001B[3J\u001B[H");
385+
386+
setTimeout(() => {
387+
if (nextMode === RawMode.Raw) {
388+
// Write all messages directly to stdout for raw scrollback mode.
389+
const allMessages = loadVisibleMessages(sessionManager, activeSessionId);
390+
for (const msg of allMessages) {
391+
process.stdout.write("\n");
392+
process.stdout.write(renderMessageToStdout(msg, nextMode) + "\n\n");
393+
}
394+
if (allMessages.length > 0) {
395+
process.stdout.write("\n\n");
396+
process.stdout.write(chalk.dim("Press ESC to exit raw mode"));
397+
}
398+
} else {
399+
// Switch to chat view to render messages.
400+
handleSelectSession(activeSessionId);
401+
}
402+
}, 200);
403+
},
404+
[handleSelectSession, sessionManager, setMode]
405+
);
406+
365407
const [stableColumns, setStableColumns] = useState(columns);
366408
useEffect(() => {
367409
const timer = setTimeout(() => setStableColumns(columns), 100);
@@ -413,7 +455,7 @@ export function App({ projectRoot, version = "", initialPrompt, onRestart }: App
413455
// eslint-disable-next-line react-hooks/exhaustive-deps -- nowTick forces periodic recalculation for spinner animation
414456
[busy, streamProgress, runningProcesses, nowTick]
415457
);
416-
const welcomeSettings = resolvedSettings;
458+
417459
const welcomeItem: SessionMessage = useMemo(
418460
() => ({
419461
id: `__welcome__${welcomeNonce}`,
@@ -430,11 +472,14 @@ export function App({ projectRoot, version = "", initialPrompt, onRestart }: App
430472
[welcomeNonce]
431473
);
432474
const staticItems = useMemo(() => {
475+
if (mode === RawMode.Raw) {
476+
return [];
477+
}
433478
if (showWelcome && view === "chat") {
434479
return [welcomeItem, ...messages];
435480
}
436481
return messages;
437-
}, [showWelcome, view, messages, welcomeItem]);
482+
}, [mode, showWelcome, view, messages, welcomeItem]);
438483

439484
const handleQuestionAnswers = useCallback(
440485
(answers: AskUserQuestionAnswers) => {
@@ -453,6 +498,10 @@ export function App({ projectRoot, version = "", initialPrompt, onRestart }: App
453498
setDismissedQuestionIds((prev) => new Set(prev).add(pendingQuestion.messageId));
454499
}, [pendingQuestion]);
455500

501+
if (mode === RawMode.Raw) {
502+
return <RawModeExitPrompt onExit={() => handleRawModeChange(RawMode.None)} />;
503+
}
504+
456505
return (
457506
<Box flexDirection="column" width={screenWidth} minWidth={80} overflowX={"visible"}>
458507
<Static items={staticItems}>
@@ -462,9 +511,8 @@ export function App({ projectRoot, version = "", initialPrompt, onRestart }: App
462511
<WelcomeScreen
463512
key={item.id}
464513
projectRoot={projectRoot}
465-
settings={welcomeSettings}
514+
settings={resolvedSettings}
466515
skills={skills}
467-
version={version}
468516
width={screenWidth}
469517
/>
470518
);
@@ -521,6 +569,7 @@ export function App({ projectRoot, version = "", initialPrompt, onRestart }: App
521569
runningProcesses={runningProcesses}
522570
onSubmit={handleSubmit}
523571
onModelConfigChange={handleModelConfigChange}
572+
onRawModeChange={handleRawModeChange}
524573
onInterrupt={handleInterrupt}
525574
onToggleProcessStdout={handleToggleProcessStdout}
526575
placeholder="Type your message..."

src/ui/AppContainer.tsx

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
import React from "react";
2+
import { AppContext } from "./contexts";
3+
import { App } from "./App";
4+
import { RawModeProvider } from "./contexts/RawModeContext";
5+
6+
const AppContainer: React.FC<{
7+
projectRoot: string;
8+
version: string;
9+
initialPrompt: string | undefined;
10+
onRestart: () => void;
11+
}> = ({ version, projectRoot, initialPrompt, onRestart }) => {
12+
return (
13+
<AppContext.Provider value={{ version: version }}>
14+
<RawModeProvider>
15+
<App initialPrompt={initialPrompt} projectRoot={projectRoot} onRestart={onRestart} />
16+
</RawModeProvider>
17+
</AppContext.Provider>
18+
);
19+
};
20+
21+
export default AppContainer;

0 commit comments

Comments
 (0)