diff --git a/src/cli.tsx b/src/cli.tsx index c3876ae5..87fb9fb5 100644 --- a/src/cli.tsx +++ b/src/cli.tsx @@ -1,7 +1,7 @@ import React from "react"; import { render } from "ink"; import { setShellIfWindows } from "./common/shell-utils"; -import { checkForNpmUpdate, promptForPendingUpdate, type PackageInfo } from "./updateCheck"; +import { checkForNpmUpdate, promptForPendingUpdate, type PackageInfo } from "./common/update-check"; import { AppContainer } from "./ui"; const args = process.argv.slice(2); diff --git a/src/common/openai-client.ts b/src/common/openai-client.ts index c1c3e4dd..ee3dd667 100644 --- a/src/common/openai-client.ts +++ b/src/common/openai-client.ts @@ -3,7 +3,7 @@ import * as os from "os"; import * as path from "path"; import OpenAI from "openai"; import { Agent, fetch as undiciFetch } from "undici"; -import { resolveCurrentSettings } from "../ui/App"; +import { resolveCurrentSettings } from "../settings"; // Custom undici Agent with a 180-second keepAlive timeout. The default // global fetch (undici) only keeps connections alive for 4 seconds, which diff --git a/src/updateCheck.ts b/src/common/update-check.ts similarity index 98% rename from src/updateCheck.ts rename to src/common/update-check.ts index fcd9bfba..09c0273c 100644 --- a/src/updateCheck.ts +++ b/src/common/update-check.ts @@ -5,8 +5,8 @@ import * as os from "os"; import * as path from "path"; import { render, type Instance } from "ink"; import chalk from "chalk"; -import { UpdatePrompt, type UpdatePromptChoice } from "./ui"; -import { killProcessTree } from "./common/process-tree"; +import { UpdatePrompt, type UpdatePromptChoice } from "../ui"; +import { killProcessTree } from "./process-tree"; export type PackageInfo = { name: string; diff --git a/src/common/runtime.ts b/src/common/validate.ts similarity index 100% rename from src/common/runtime.ts rename to src/common/validate.ts diff --git a/src/prompt.ts b/src/prompt.ts index ba9bf231..669e5759 100644 --- a/src/prompt.ts +++ b/src/prompt.ts @@ -2,8 +2,8 @@ import { execFileSync, execSync } from "child_process"; import * as fs from "fs"; import * as os from "os"; import * as path from "path"; -import { fileURLToPath } from "url"; import ejs from "ejs"; +import { fileURLToPath } from "url"; import type { SessionMessage } from "./session"; import { findGitBashPath, resolveShellPath } from "./common/shell-utils"; import { supportsMultimodal } from "./common/model-capabilities"; @@ -166,8 +166,7 @@ function getCurrentDateAndModelPrompt(model?: string): string { export function getSystemPrompt(_projectRoot: string, options: PromptToolOptions = {}): string { const toolDocs = readToolDocs(getExtensionRoot(), options); - const basePrompt = toolDocs ? `${SYSTEM_PROMPT_BASE}\n\n# Available Tools\n\n${toolDocs}` : SYSTEM_PROMPT_BASE; - return basePrompt; + return toolDocs ? `${SYSTEM_PROMPT_BASE}\n\n# Available Tools\n\n${toolDocs}` : SYSTEM_PROMPT_BASE; } export function getCompactPrompt(sessionMessages: SessionMessage[]): string { @@ -287,7 +286,7 @@ function getUnameInfo(): string { } } -function getExtensionRoot(): string { +export function getExtensionRoot(): string { // Prefer `__dirname` which is always available in the CJS bundle output. // Fall back to `import.meta.url` for ESM test environments (tsx --test). if (typeof __dirname !== "undefined") { diff --git a/src/session.ts b/src/session.ts index a9fc39e8..876d2561 100644 --- a/src/session.ts +++ b/src/session.ts @@ -2,7 +2,6 @@ import * as fs from "fs"; import * as path from "path"; import * as os from "os"; import * as crypto from "crypto"; -import { fileURLToPath } from "url"; import matter from "gray-matter"; import ejs from "ejs"; import type { ChatCompletionMessageParam, ChatCompletionContentPart } from "openai/resources/chat/completions"; @@ -12,6 +11,7 @@ import { DEEPSEEK_V4_MODELS, supportsMultimodal } from "./common/model-capabilit import { getCompactPrompt, getDefaultSkillPrompt, + getExtensionRoot, getRuntimeContext, getSystemPrompt, getTools, @@ -135,15 +135,6 @@ function accumulateUsagePerModel( return usagePerModel; } -function getExtensionRoot(): string { - if (typeof __dirname !== "undefined") { - return path.resolve(__dirname, ".."); - } - - const currentFilePath = fileURLToPath(import.meta.url); - return path.resolve(path.dirname(currentFilePath), ".."); -} - function getTotalTokens(usage: ModelUsage | null | undefined): number { if (!isUsageRecord(usage)) { return 0; diff --git a/src/settings.ts b/src/settings.ts index e0b17768..b7a7a777 100644 --- a/src/settings.ts +++ b/src/settings.ts @@ -1,4 +1,7 @@ import { defaultsToThinkingMode } from "./common/model-capabilities"; +import * as fs from "fs"; +import * as os from "os"; +import * as path from "path"; export type DeepcodingEnv = Record & { MODEL?: string; @@ -370,3 +373,88 @@ export function applyModelConfigSelection( return { settings: next, changed: true }; } + +// --------------------------------------------------------------------------- +// Default constants +// --------------------------------------------------------------------------- + +export const DEFAULT_MODEL = "deepseek-v4-pro"; +export const DEFAULT_BASE_URL = "https://api.deepseek.com"; + +// --------------------------------------------------------------------------- +// Settings file I/O +// --------------------------------------------------------------------------- + +export function getUserSettingsPath(): string { + return path.join(os.homedir(), ".deepcode", "settings.json"); +} + +export function getProjectSettingsPath(projectRoot: string): string { + return path.join(projectRoot, ".deepcode", "settings.json"); +} + +export function readSettingsFile(settingsPath: string): DeepcodingSettings | null { + try { + if (!fs.existsSync(settingsPath)) { + return null; + } + const raw = fs.readFileSync(settingsPath, "utf8"); + return JSON.parse(raw) as DeepcodingSettings; + } catch { + return null; + } +} + +export function readSettings(): DeepcodingSettings | null { + return readSettingsFile(getUserSettingsPath()); +} + +export function readProjectSettings(projectRoot: string = process.cwd()): DeepcodingSettings | null { + return readSettingsFile(getProjectSettingsPath(projectRoot)); +} + +function writeSettingsFile(settingsPath: string, settings: DeepcodingSettings): void { + fs.mkdirSync(path.dirname(settingsPath), { recursive: true }); + fs.writeFileSync(settingsPath, `${JSON.stringify(settings, null, 2)}\n`, "utf8"); +} + +export function writeSettings(settings: DeepcodingSettings): void { + const settingsPath = getUserSettingsPath(); + writeSettingsFile(settingsPath, settings); +} + +export function writeProjectSettings(settings: DeepcodingSettings, projectRoot: string = process.cwd()): void { + const settingsPath = getProjectSettingsPath(projectRoot); + writeSettingsFile(settingsPath, settings); +} + +export function writeModelConfigSelection( + selection: ModelConfigSelection, + current: ModelConfigSelection = resolveCurrentSettings(), + projectRoot: string = process.cwd() +): { changed: boolean; settings: DeepcodingSettings } { + const projectSettingsPath = getProjectSettingsPath(projectRoot); + const shouldWriteProjectSettings = fs.existsSync(projectSettingsPath); + const rawSettings = shouldWriteProjectSettings ? readProjectSettings(projectRoot) : readSettings(); + const result = applyModelConfigSelection(rawSettings, current, selection); + if (result.changed) { + if (shouldWriteProjectSettings) { + writeProjectSettings(result.settings, projectRoot); + } else { + writeSettings(result.settings); + } + } + return result; +} + +export function resolveCurrentSettings(projectRoot: string = process.cwd()): ResolvedDeepcodingSettings { + return resolveSettingsSources( + readSettings(), + readProjectSettings(projectRoot), + { + model: DEFAULT_MODEL, + baseURL: DEFAULT_BASE_URL, + }, + process.env + ); +} diff --git a/src/tests/askUserQuestion.test.ts b/src/tests/ask-user-question.test.ts similarity index 100% rename from src/tests/askUserQuestion.test.ts rename to src/tests/ask-user-question.test.ts diff --git a/src/tests/clipboard.test.ts b/src/tests/clipboard.test.ts index dbe9ff95..3ca892eb 100644 --- a/src/tests/clipboard.test.ts +++ b/src/tests/clipboard.test.ts @@ -5,7 +5,7 @@ import * as os from "os"; import * as path from "path"; // eslint-disable-next-line @typescript-eslint/consistent-type-imports -type ClipboardModule = typeof import("../ui/clipboard"); +type ClipboardModule = typeof import("../ui/core/clipboard"); const ORIGINAL_PATH = process.env.PATH; const ORIGINAL_PLATFORM = process.platform; @@ -30,7 +30,7 @@ function withPlatform(platform: NodeJS.Platform, fn: () => T): T { test("readClipboardImage returns null when no clipboard helpers are installed", async () => { // Reload module so it picks up the patched PATH at spawn time. - const moduleUrl = new URL(`../ui/clipboard.ts?t=${Date.now()}`, import.meta.url).href; + const moduleUrl = new URL(`../ui/core/clipboard.ts?t=${Date.now()}`, import.meta.url).href; const { readClipboardImage } = (await import(moduleUrl)) as ClipboardModule; const result = withCleanPath(() => readClipboardImage()); assert.equal(result, null); @@ -63,7 +63,7 @@ test( { mode: 0o755 } ); - const moduleUrl = new URL(`../ui/clipboard.ts?t=${Date.now()}`, import.meta.url).href; + const moduleUrl = new URL(`../ui/core/clipboard.ts?t=${Date.now()}`, import.meta.url).href; const { readClipboardImage } = (await import(moduleUrl)) as ClipboardModule; process.env.PATH = binDir; diff --git a/src/tests/dropdownMenu.test.ts b/src/tests/dropdown-menu.test.ts similarity index 98% rename from src/tests/dropdownMenu.test.ts rename to src/tests/dropdown-menu.test.ts index 3e4e3ef5..e6a0a1a4 100644 --- a/src/tests/dropdownMenu.test.ts +++ b/src/tests/dropdown-menu.test.ts @@ -1,6 +1,6 @@ import { test } from "node:test"; import assert from "node:assert/strict"; -import { calculateVisibleStart } from "../ui/DropdownMenu"; +import { calculateVisibleStart } from "../ui/components/DropdownMenu"; test("calculateVisibleStart centers active item when possible", () => { // 10 items, max 5 visible, active index 4 (middle) diff --git a/src/tests/exitSummary.test.ts b/src/tests/exit-summary.test.ts similarity index 100% rename from src/tests/exitSummary.test.ts rename to src/tests/exit-summary.test.ts diff --git a/src/tests/fileMentions.test.ts b/src/tests/file-mentions.test.ts similarity index 99% rename from src/tests/fileMentions.test.ts rename to src/tests/file-mentions.test.ts index b382eeed..93d9bccc 100644 --- a/src/tests/fileMentions.test.ts +++ b/src/tests/file-mentions.test.ts @@ -10,7 +10,7 @@ import { replaceCurrentFileMentionToken, scanFileMentionItems, type FileMentionItem, -} from "../ui/fileMentions"; +} from "../ui/core/file-mentions"; test("getCurrentFileMentionToken detects bare @file tokens under the cursor", () => { assert.deepEqual(getCurrentFileMentionToken({ text: "review @src/app.ts please", cursor: 10 }), { diff --git a/src/tests/loadingText.test.ts b/src/tests/loading-text.test.ts similarity index 100% rename from src/tests/loadingText.test.ts rename to src/tests/loading-text.test.ts diff --git a/src/tests/messageView.test.ts b/src/tests/message-view.test.ts similarity index 100% rename from src/tests/messageView.test.ts rename to src/tests/message-view.test.ts diff --git a/src/tests/permission-prompt.test.ts b/src/tests/permission-prompt.test.ts index aa4f372d..4f1d87e9 100644 --- a/src/tests/permission-prompt.test.ts +++ b/src/tests/permission-prompt.test.ts @@ -1,6 +1,6 @@ import { test } from "node:test"; import assert from "node:assert/strict"; -import { getScopeRiskColor } from "../ui/PermissionPrompt"; +import { getScopeRiskColor } from "../ui/views/PermissionPrompt"; test("getScopeRiskColor maps permission scopes by risk", () => { assert.equal(getScopeRiskColor("read-in-cwd"), "#22c55e"); diff --git a/src/tests/promptBuffer.test.ts b/src/tests/prompt-buffer.test.ts similarity index 100% rename from src/tests/promptBuffer.test.ts rename to src/tests/prompt-buffer.test.ts diff --git a/src/tests/promptInputKeys.test.ts b/src/tests/prompt-input-keys.test.ts similarity index 99% rename from src/tests/promptInputKeys.test.ts rename to src/tests/prompt-input-keys.test.ts index 4f8b4d95..4ca564f9 100644 --- a/src/tests/promptInputKeys.test.ts +++ b/src/tests/prompt-input-keys.test.ts @@ -14,13 +14,11 @@ import { getPromptCursorPlacement, getPromptReturnKeyAction, isClearImageAttachmentsShortcut, - parseTerminalInput, removeCurrentSlashToken, toggleSkillSelection, renderBufferWithCursor, buildInitPromptSubmission, buildPromptDraftFromSessionMessage, - dispatchTerminalInput, disableTerminalExtendedKeys, enableTerminalExtendedKeys, EMPTY_BUFFER, @@ -28,6 +26,7 @@ import { backspace, } from "../ui"; import type { SessionMessage, SkillInfo } from "../session"; +import { dispatchTerminalInput, parseTerminalInput } from "../ui/hooks"; function collectDispatchedInput(data: string) { const events: ReturnType[] = []; diff --git a/src/tests/promptUndoRedo.test.ts b/src/tests/prompt-undo-redo.test.ts similarity index 98% rename from src/tests/promptUndoRedo.test.ts rename to src/tests/prompt-undo-redo.test.ts index c1999f15..d4590fb6 100644 --- a/src/tests/promptUndoRedo.test.ts +++ b/src/tests/prompt-undo-redo.test.ts @@ -8,7 +8,7 @@ import { recordPromptEdit, redoPromptEdit, undoPromptEdit, -} from "../ui/promptUndoRedo"; +} from "../ui/core/prompt-undo-redo"; test("prompt undo and redo restore edited buffer states", () => { const history = createPromptUndoRedoState(); diff --git a/src/tests/sessionList.test.ts b/src/tests/session-list.test.ts similarity index 100% rename from src/tests/sessionList.test.ts rename to src/tests/session-list.test.ts diff --git a/src/tests/session.test.ts b/src/tests/session.test.ts index 95de8e35..6af3cb2d 100644 --- a/src/tests/session.test.ts +++ b/src/tests/session.test.ts @@ -5,7 +5,8 @@ import * as fs from "fs"; import * as os from "os"; import * as path from "path"; import { GitFileHistory } from "../common/file-history"; -import { SessionManager, type SessionMessage } from "../session"; +import { type SessionMessage } from "../session"; +import { SessionManager } from "../session"; const originalFetch = globalThis.fetch; const originalConsoleWarn = console.warn; diff --git a/src/tests/slashCommands.test.ts b/src/tests/slash-commands.test.ts similarity index 100% rename from src/tests/slashCommands.test.ts rename to src/tests/slash-commands.test.ts diff --git a/src/tests/thinkingState.test.ts b/src/tests/thinking-state.test.ts similarity index 100% rename from src/tests/thinkingState.test.ts rename to src/tests/thinking-state.test.ts diff --git a/src/tests/updateCheck.test.ts b/src/tests/update-check.test.ts similarity index 88% rename from src/tests/updateCheck.test.ts rename to src/tests/update-check.test.ts index ce77fe5e..93b30360 100644 --- a/src/tests/updateCheck.test.ts +++ b/src/tests/update-check.test.ts @@ -1,6 +1,6 @@ import { test } from "node:test"; import assert from "node:assert/strict"; -import { compareVersions, parseNpmViewVersion } from "../updateCheck"; +import { compareVersions, parseNpmViewVersion } from "../common/update-check"; test("compareVersions orders semantic versions", () => { assert.equal(compareVersions("0.1.4", "0.1.3"), 1); diff --git a/src/tests/welcomeScreen.test.ts b/src/tests/welcome-screen.test.ts similarity index 100% rename from src/tests/welcomeScreen.test.ts rename to src/tests/welcome-screen.test.ts diff --git a/src/tools/edit-handler.ts b/src/tools/edit-handler.ts index 454a673b..6460d611 100644 --- a/src/tools/edit-handler.ts +++ b/src/tools/edit-handler.ts @@ -8,7 +8,7 @@ import { readTextFileWithMetadata, writeTextFile, } from "../common/file-utils"; -import { executeValidatedTool, semanticBoolean } from "../common/runtime"; +import { executeValidatedTool, semanticBoolean } from "../common/validate"; import { createSnippet, getFileState, diff --git a/src/tools/update-plan-handler.ts b/src/tools/update-plan-handler.ts index 7c7198ea..11439784 100644 --- a/src/tools/update-plan-handler.ts +++ b/src/tools/update-plan-handler.ts @@ -1,6 +1,6 @@ import { z } from "zod"; import type { ToolExecutionContext, ToolExecutionResult } from "./executor"; -import { executeValidatedTool } from "../common/runtime"; +import { executeValidatedTool } from "../common/validate"; const updatePlanSchema = z.strictObject({ plan: z.string().trim().min(1, "plan must not be empty."), diff --git a/src/tools/write-handler.ts b/src/tools/write-handler.ts index a4c81bf3..35ecdb2d 100644 --- a/src/tools/write-handler.ts +++ b/src/tools/write-handler.ts @@ -9,7 +9,7 @@ import { readTextFileWithMetadata, writeTextFile, } from "../common/file-utils"; -import { executeValidatedTool } from "../common/runtime"; +import { executeValidatedTool } from "../common/validate"; import { getFileState, isAbsoluteFilePath, isFullFileView, normalizeFilePath, recordFileState } from "../common/state"; const writeSchema = z.strictObject({ diff --git a/src/AsciiArt.ts b/src/ui/ascii-art.ts similarity index 100% rename from src/AsciiArt.ts rename to src/ui/ascii-art.ts diff --git a/src/ui/DropdownMenu.tsx b/src/ui/components/DropdownMenu/index.tsx similarity index 99% rename from src/ui/DropdownMenu.tsx rename to src/ui/components/DropdownMenu/index.tsx index 6593ff8d..cf323141 100644 --- a/src/ui/DropdownMenu.tsx +++ b/src/ui/components/DropdownMenu/index.tsx @@ -64,7 +64,7 @@ const DropdownMenu = React.memo(function DropdownMenu({ maxVisible = 8, width, title, - titleColor = "magenta", + titleColor = "#229ac3", activeColor = "cyanBright", helpText, emptyText = "No items found", diff --git a/src/ui/components/FileMentionMenu/index.tsx b/src/ui/components/FileMentionMenu/index.tsx index ce9a8ee8..f00b367e 100644 --- a/src/ui/components/FileMentionMenu/index.tsx +++ b/src/ui/components/FileMentionMenu/index.tsx @@ -1,8 +1,8 @@ import React, { useEffect, useState } from "react"; import { Box, Text } from "ink"; import { useInput } from "ink"; -import DropdownMenu from "../../DropdownMenu"; -import type { FileMentionItem, FileMentionToken } from "../../fileMentions"; +import DropdownMenu from "../DropdownMenu"; +import type { FileMentionItem, FileMentionToken } from "../../core/file-mentions"; type Props = { open: boolean; diff --git a/src/ui/components/ModelsDropdown/index.tsx b/src/ui/components/ModelsDropdown/index.tsx index bdd68ab4..6e807569 100644 --- a/src/ui/components/ModelsDropdown/index.tsx +++ b/src/ui/components/ModelsDropdown/index.tsx @@ -1,6 +1,6 @@ import React, { useEffect, useState } from "react"; import { useInput } from "ink"; -import DropdownMenu from "../../DropdownMenu"; +import DropdownMenu from "../DropdownMenu"; import type { ModelConfigSelection, ReasoningEffort } from "../../../settings"; type ModelStep = "model" | "thinking"; diff --git a/src/ui/components/RawModelDropdown/index.tsx b/src/ui/components/RawModelDropdown/index.tsx index 33970136..67f053c9 100644 --- a/src/ui/components/RawModelDropdown/index.tsx +++ b/src/ui/components/RawModelDropdown/index.tsx @@ -1,6 +1,6 @@ import React, { useState } from "react"; import { useInput } from "ink"; -import DropdownMenu from "../../DropdownMenu"; +import DropdownMenu from "../DropdownMenu"; import type { RawMode } from "../../contexts"; import { RAW_COMMAND_MODELS, useRawModeContext } from "../../contexts"; diff --git a/src/ui/components/SkillsDropdown/index.tsx b/src/ui/components/SkillsDropdown/index.tsx index b320d249..4ec53397 100644 --- a/src/ui/components/SkillsDropdown/index.tsx +++ b/src/ui/components/SkillsDropdown/index.tsx @@ -1,8 +1,8 @@ -import DropdownMenu from "../../DropdownMenu"; +import DropdownMenu from "../DropdownMenu"; import React, { useEffect, useState } from "react"; import type { SkillInfo } from "../../../session"; import { useInput } from "ink"; -import { isSkillSelected } from "../../SlashCommandMenu"; +import { isSkillSelected } from "../../views/SlashCommandMenu"; const SkillsDropdown: React.FC<{ open: boolean; diff --git a/src/ui/components/index.ts b/src/ui/components/index.ts index 635f733c..f3cbd675 100644 --- a/src/ui/components/index.ts +++ b/src/ui/components/index.ts @@ -4,3 +4,4 @@ export { RawModeExitPrompt } from "./RawModeExitPrompt"; export { default as SkillsDropdown } from "./SkillsDropdown"; export { default as ModelsDropdown } from "./ModelsDropdown"; export { default as FileMentionMenu } from "./FileMentionMenu"; +export { default as DropdownMenu } from "./DropdownMenu"; diff --git a/src/ui/constants.ts b/src/ui/constants.ts index 43372f80..7b336f10 100644 --- a/src/ui/constants.ts +++ b/src/ui/constants.ts @@ -1,5 +1,3 @@ -// UI-level shared constants used across components. - /** Separator used when rendering command arguments inline (e.g., `arg1 | arg2 | arg3`). */ export const ARGS_SEPARATOR = " | "; diff --git a/src/ui/contexts/RawModeContext.tsx b/src/ui/contexts/RawModeContext.tsx index 3198a3ae..969cea71 100644 --- a/src/ui/contexts/RawModeContext.tsx +++ b/src/ui/contexts/RawModeContext.tsx @@ -1,5 +1,5 @@ import React, { createContext, useCallback, useContext, useRef, useState } from "react"; -import type { DropdownMenuItem } from "../DropdownMenu"; +import type { DropdownMenuItem } from "../components/DropdownMenu"; export enum RawMode { None = "Normal mode", diff --git a/src/ui/askUserQuestion.ts b/src/ui/core/ask-user-question.ts similarity index 98% rename from src/ui/askUserQuestion.ts rename to src/ui/core/ask-user-question.ts index 8d168d86..8a07e400 100644 --- a/src/ui/askUserQuestion.ts +++ b/src/ui/core/ask-user-question.ts @@ -1,4 +1,4 @@ -import type { SessionMessage, SessionStatus } from "../session"; +import type { SessionMessage, SessionStatus } from "../../session"; export type AskUserQuestionOption = { label: string; diff --git a/src/ui/clipboard.ts b/src/ui/core/clipboard.ts similarity index 100% rename from src/ui/clipboard.ts rename to src/ui/core/clipboard.ts diff --git a/src/ui/fileMentions.ts b/src/ui/core/file-mentions.ts similarity index 99% rename from src/ui/fileMentions.ts rename to src/ui/core/file-mentions.ts index cbacbe6d..ae9c8b99 100644 --- a/src/ui/fileMentions.ts +++ b/src/ui/core/file-mentions.ts @@ -1,7 +1,7 @@ import * as fs from "fs"; import * as path from "path"; import ignore from "ignore"; -import type { PromptBufferState } from "./promptBuffer"; +import type { PromptBufferState } from "./prompt-buffer"; export type FileMentionItem = { path: string; diff --git a/src/ui/loadingText.ts b/src/ui/core/loading-text.ts similarity index 96% rename from src/ui/loadingText.ts rename to src/ui/core/loading-text.ts index bfb97d4c..2c965ea3 100644 --- a/src/ui/loadingText.ts +++ b/src/ui/core/loading-text.ts @@ -1,4 +1,4 @@ -import type { LlmStreamProgress, SessionEntry } from "../session"; +import type { LlmStreamProgress, SessionEntry } from "../../session"; type RunningProcesses = SessionEntry["processes"]; diff --git a/src/ui/promptBuffer.ts b/src/ui/core/prompt-buffer.ts similarity index 100% rename from src/ui/promptBuffer.ts rename to src/ui/core/prompt-buffer.ts diff --git a/src/ui/promptUndoRedo.ts b/src/ui/core/prompt-undo-redo.ts similarity index 95% rename from src/ui/promptUndoRedo.ts rename to src/ui/core/prompt-undo-redo.ts index 9d30f57b..fd2870a6 100644 --- a/src/ui/promptUndoRedo.ts +++ b/src/ui/core/prompt-undo-redo.ts @@ -1,4 +1,4 @@ -import type { PromptBufferState } from "./promptBuffer"; +import type { PromptBufferState } from "./prompt-buffer"; export type PromptUndoRedoState = { undoStack: PromptBufferState[]; diff --git a/src/ui/slashCommands.ts b/src/ui/core/slash-commands.ts similarity index 98% rename from src/ui/slashCommands.ts rename to src/ui/core/slash-commands.ts index 6d9b7cc1..04840baa 100644 --- a/src/ui/slashCommands.ts +++ b/src/ui/core/slash-commands.ts @@ -1,4 +1,4 @@ -import type { SkillInfo } from "../session"; +import type { SkillInfo } from "../../session"; export type SlashCommandKind = | "skill" diff --git a/src/ui/thinkingState.ts b/src/ui/core/thinking-state.ts similarity index 58% rename from src/ui/thinkingState.ts rename to src/ui/core/thinking-state.ts index 6f419e24..02245091 100644 --- a/src/ui/thinkingState.ts +++ b/src/ui/core/thinking-state.ts @@ -1,4 +1,4 @@ -import type { SessionMessage } from "../session"; +import type { SessionMessage } from "../../session"; /** * Returns the message id of the assistant "thinking" message that should stay @@ -21,3 +21,18 @@ export function findExpandedThinkingId(messages: SessionMessage[]): string | nul } return expanded; } + +/** + * Returns whether a message's thinking block should be rendered collapsed. + * A thinking message is collapsed when its id does not match the currently + * expanded thinking id. + */ +export function isCollapsedThinking(message: SessionMessage, expandedId: string | null): boolean { + if (message.role !== "assistant") { + return false; + } + if (!message.meta?.asThinking) { + return false; + } + return message.id !== expandedId; +} diff --git a/src/ui/exitSummary.ts b/src/ui/exit-summary.ts similarity index 100% rename from src/ui/exitSummary.ts rename to src/ui/exit-summary.ts diff --git a/src/ui/prompt/cursor.ts b/src/ui/hooks/cursor.ts similarity index 99% rename from src/ui/prompt/cursor.ts rename to src/ui/hooks/cursor.ts index aefea342..07cc5779 100644 --- a/src/ui/prompt/cursor.ts +++ b/src/ui/hooks/cursor.ts @@ -1,5 +1,5 @@ import { useLayoutEffect, useRef } from "react"; -import type { PromptBufferState } from "../promptBuffer"; +import type { PromptBufferState } from "../core/prompt-buffer"; type CursorPlacement = { rowsUp: number; diff --git a/src/ui/prompt/index.ts b/src/ui/hooks/index.ts similarity index 51% rename from src/ui/prompt/index.ts rename to src/ui/hooks/index.ts index 6435f620..86245b65 100644 --- a/src/ui/prompt/index.ts +++ b/src/ui/hooks/index.ts @@ -9,3 +9,9 @@ export { useTerminalFocusReporting, getPromptCursorPlacement, } from "./cursor"; + +export { usePasteHandling } from "./usePasteHandling"; +export type { PasteRegion, PasteHandlingState, PasteHandlingActions } from "./usePasteHandling"; + +export { useHistoryNavigation } from "./useHistoryNavigation"; +export type { HistoryNavigationState, HistoryNavigationActions } from "./useHistoryNavigation"; diff --git a/src/ui/hooks/useHistoryNavigation.ts b/src/ui/hooks/useHistoryNavigation.ts new file mode 100644 index 00000000..54ccabfd --- /dev/null +++ b/src/ui/hooks/useHistoryNavigation.ts @@ -0,0 +1,67 @@ +import type React from "react"; +import { useCallback, useState } from "react"; +import type { PromptBufferState } from "../core/prompt-buffer"; + +export type HistoryNavigationState = { + historyCursor: number; + draftBeforeHistory: string | null; +}; + +export type HistoryNavigationActions = { + /** + * Navigate through prompt history. Pass -1 for previous, 1 for next. + * Stores current draft before entering history mode and restores it when + * scrolling past the last entry. + */ + navigateHistory: (direction: -1 | 1) => void; + /** Exit history browsing mode, restoring the pre-history draft if any. */ + exitHistoryBrowsing: () => void; +}; + +export function useHistoryNavigation( + buffer: PromptBufferState, + setBuffer: React.Dispatch>, + promptHistory: string[] +): HistoryNavigationState & HistoryNavigationActions { + const [historyCursor, setHistoryCursor] = useState(-1); + const [draftBeforeHistory, setDraftBeforeHistory] = useState(null); + + const exitHistoryBrowsing = useCallback((): void => { + setHistoryCursor(-1); + setDraftBeforeHistory(null); + }, []); + + function navigateHistory(direction: -1 | 1): void { + if (promptHistory.length === 0) { + return; + } + + const previousCursor = historyCursor === -1 ? promptHistory.length : historyCursor; + const nextCursor = Math.max(0, Math.min(promptHistory.length, previousCursor + direction)); + // Capture the current draft synchronously before `setDraftBeforeHistory`. + const draft = historyCursor === -1 ? buffer.text : draftBeforeHistory; + + if (historyCursor === -1) { + setDraftBeforeHistory(buffer.text); + } + + if (nextCursor === promptHistory.length) { + const text = draft ?? ""; + setBuffer({ text, cursor: text.length }); + setHistoryCursor(-1); + setDraftBeforeHistory(null); + return; + } + + const text = promptHistory[nextCursor] ?? ""; + setBuffer({ text, cursor: text.length }); + setHistoryCursor(nextCursor); + } + + return { + historyCursor, + draftBeforeHistory, + navigateHistory, + exitHistoryBrowsing, + }; +} diff --git a/src/ui/hooks/usePasteHandling.ts b/src/ui/hooks/usePasteHandling.ts new file mode 100644 index 00000000..beaf0859 --- /dev/null +++ b/src/ui/hooks/usePasteHandling.ts @@ -0,0 +1,157 @@ +import type React from "react"; +import { useEffect, useRef, useState } from "react"; +import type { PromptBufferState } from "../core/prompt-buffer"; +import { cleanPasteContent, findPasteMarkerContaining, hasActivePasteMarkers, insertText } from "../core/prompt-buffer"; + +export type PasteRegion = { + start: number; + end: number; + content: string; + marker: string; +}; + +export type PasteHandlingState = { + /** Ref holding all paste content keyed by paste ID. */ + pastesRef: React.RefObject>; + /** Ref holding expanded paste regions for Ctrl+O toggle. */ + expandedRegionsRef: React.RefObject>; + /** Counter for generating unique paste IDs. */ + pasteCounterRef: React.RefObject; + /** Whether any paste marker is currently collapsed. */ + hasCollapsedMarkers: boolean; + /** Whether any paste region has been expanded. */ + hasExpandedRegions: boolean; +}; + +export type PasteHandlingActions = { + /** + * Process pasted text. Short pastes (<1000 chars, ≤9 newlines) are inserted + * inline. Larger pastes receive a collapsible marker. + */ + handlePaste: (pastedText: string) => void; + /** Expand a collapsed paste marker at the cursor, or collapse an expanded region. */ + expandPasteMarkerAtCursor: () => void; + /** Reset all paste-related state. */ + resetPastes: () => void; +}; + +export function usePasteHandling( + buffer: PromptBufferState, + updateBuffer: (updater: (state: PromptBufferState) => PromptBufferState) => void, + setStatusMessage: (msg: string | null) => void +): PasteHandlingState & PasteHandlingActions { + const pastesRef = useRef>(new Map()); + const pasteCounterRef = useRef(0); + const expandedRegionsRef = useRef>(new Map()); + const [hasCollapsedMarkers, setHasCollapsedMarkers] = useState(false); + const [hasExpandedRegions, setHasExpandedRegions] = useState(false); + + function refreshDerivedFlags(): void { + setHasCollapsedMarkers(hasActivePasteMarkers(buffer.text, pastesRef.current)); + setHasExpandedRegions(expandedRegionsRef.current.size > 0); + } + + // Recompute derived flags whenever the buffer text changes, so they stay + // in sync after any state update (e.g. large paste, expand/collapse, undo). + useEffect(() => { + refreshDerivedFlags(); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [buffer.text]); + + function handlePaste(pastedText: string): void { + const totalChars = pastedText.length; + + if (totalChars <= 1000) { + const newlineCount = (pastedText.match(/\n/g) ?? []).length; + if (newlineCount <= 9) { + const clean = pastedText + .replace(/\r\n|\r/g, "\n") + .replace(/[\x00-\x08\x0b\x0c\x0e-\x1f\x7f]/g, "") + .replace(/\t/g, " "); + updateBuffer((s) => insertText(s, clean)); + return; + } + } + + // Large paste: store raw text, insert marker. + const lineCount = (pastedText.match(/\n/g) ?? []).length + 1; + pasteCounterRef.current += 1; + const pasteId = pasteCounterRef.current; + pastesRef.current.set(pasteId, pastedText); + + const marker = + lineCount > 10 ? `[paste #${pasteId} +${lineCount} lines]` : `[paste #${pasteId} ${totalChars} chars]`; + + updateBuffer((s) => insertText(s, marker)); + refreshDerivedFlags(); + } + + function expandPasteMarkerAtCursor(): void { + // Collapse an already-expanded region at the cursor. + for (const [id, region] of expandedRegionsRef.current) { + if (buffer.cursor >= region.start && buffer.cursor <= region.end) { + expandedRegionsRef.current.delete(id); + pastesRef.current.set(id, region.content); + setTimeout(() => { + updateBuffer((s) => { + const text = s.text.slice(0, region.start) + region.marker + s.text.slice(region.end); + return { text, cursor: region.start + region.marker.length }; + }); + refreshDerivedFlags(); + }, 0); + refreshDerivedFlags(); + return; + } + } + + // Expand a paste marker. + const marker = findPasteMarkerContaining(buffer); + if (!marker) { + setStatusMessage("No paste marker at cursor"); + return; + } + const content = pastesRef.current.get(marker.id); + if (!content) { + setStatusMessage("Paste content not found"); + return; + } + + const pasteId = marker.id; + const originalMarker = buffer.text.slice(marker.start, marker.end); + pastesRef.current.delete(pasteId); + + setTimeout(() => { + updateBuffer((s) => { + const text = s.text.slice(0, marker.start) + cleanPasteContent(content) + s.text.slice(marker.end); + const newEnd = marker.start + content.length; + expandedRegionsRef.current.set(pasteId, { + start: marker.start, + end: newEnd, + content, + marker: originalMarker, + }); + return { text, cursor: marker.start }; + }); + refreshDerivedFlags(); + }, 0); + refreshDerivedFlags(); + } + + function resetPastes(): void { + pastesRef.current.clear(); + expandedRegionsRef.current.clear(); + pasteCounterRef.current = 0; + refreshDerivedFlags(); + } + + return { + pastesRef, + expandedRegionsRef, + pasteCounterRef, + hasCollapsedMarkers, + hasExpandedRegions, + handlePaste, + expandPasteMarkerAtCursor, + resetPastes, + }; +} diff --git a/src/ui/prompt/useTerminalInput.ts b/src/ui/hooks/useTerminalInput.ts similarity index 100% rename from src/ui/prompt/useTerminalInput.ts rename to src/ui/hooks/useTerminalInput.ts diff --git a/src/ui/index.ts b/src/ui/index.ts index 13489037..ae1109ad 100644 --- a/src/ui/index.ts +++ b/src/ui/index.ts @@ -4,18 +4,11 @@ import { MODEL_COMMAND_THINKING_OPTIONS, } from "./components/ModelsDropdown"; -export { - readSettings, - readProjectSettings, - writeSettings, - writeProjectSettings, - writeModelConfigSelection, - resolveCurrentSettings, - buildPromptDraftFromSessionMessage, -} from "./App"; -export { createOpenAIClient } from "../common/openai-client"; -export { default as AppContainer } from "./AppContainer"; -export { AskUserQuestionPrompt } from "./AskUserQuestionPrompt"; +export { getThinkingOptionIndex, MODEL_COMMAND_MODELS, MODEL_COMMAND_THINKING_OPTIONS }; +export { buildPromptDraftFromSessionMessage } from "./utils"; +export { disableTerminalExtendedKeys, enableTerminalExtendedKeys, getPromptCursorPlacement } from "./hooks/cursor"; +export { default as AppContainer } from "./views/AppContainer"; +export { AskUserQuestionPrompt } from "./views/AskUserQuestionPrompt"; export { MessageView } from "./components"; export { parseDiffPreview } from "./components/MessageView/utils"; export { @@ -30,19 +23,13 @@ export { getPromptReturnKeyAction, renderBufferWithCursor, buildInitPromptSubmission, - useTerminalInput, - parseTerminalInput, - dispatchTerminalInput, type PromptSubmission, type PromptDraft, - type InputKey, -} from "./PromptInput"; -export { getThinkingOptionIndex, MODEL_COMMAND_MODELS, MODEL_COMMAND_THINKING_OPTIONS }; -export { disableTerminalExtendedKeys, enableTerminalExtendedKeys, getPromptCursorPlacement } from "./prompt/cursor"; -export { SessionList, formatSessionTitle, filterSessions, formatSessionStatus } from "./SessionList"; -export { ThemedGradient } from "./ThemedGradient"; -export { UpdatePrompt, type UpdatePromptChoice } from "./UpdatePrompt"; -export { WelcomeScreen, formatHomeRelativePath, buildWelcomeTips } from "./WelcomeScreen"; +} from "./views/PromptInput"; +export { SessionList, formatSessionTitle, filterSessions, formatSessionStatus } from "./views/SessionList"; +export { ThemedGradient } from "./views/ThemedGradient"; +export { UpdatePrompt, type UpdatePromptChoice } from "./views/UpdatePrompt"; +export { WelcomeScreen, formatHomeRelativePath, buildWelcomeTips } from "./views/WelcomeScreen"; export { findPendingAskUserQuestion, formatAskUserQuestionAnswers, @@ -51,9 +38,9 @@ export { type AskUserQuestionItem, type PendingAskUserQuestion, type AskUserQuestionAnswers, -} from "./askUserQuestion"; -export { readClipboardImage, type ClipboardImage } from "./clipboard"; -export { buildLoadingText, type LoadingTextInput } from "./loadingText"; +} from "./core/ask-user-question"; +export { readClipboardImage, type ClipboardImage } from "./core/clipboard"; +export { buildLoadingText, type LoadingTextInput } from "./core/loading-text"; export { renderMarkdown, renderMarkdownSegments, type MarkdownSegment } from "./components/MessageView/markdown"; export { EMPTY_BUFFER, @@ -75,7 +62,7 @@ export { isEmpty, getCurrentSlashToken, type PromptBufferState, -} from "./promptBuffer"; +} from "./core/prompt-buffer"; export { BUILTIN_SLASH_COMMANDS, buildSlashCommands, @@ -85,7 +72,7 @@ export { formatSlashCommandLabel, type SlashCommandKind, type SlashCommandItem, -} from "./slashCommands"; +} from "./core/slash-commands"; export { filterFileMentionItems, formatFileMentionPath, @@ -94,6 +81,6 @@ export { scanFileMentionItems, type FileMentionItem, type FileMentionToken, -} from "./fileMentions"; -export { findExpandedThinkingId } from "./thinkingState"; -export { buildExitSummaryText } from "./exitSummary"; +} from "./core/file-mentions"; +export { findExpandedThinkingId, isCollapsedThinking } from "./core/thinking-state"; +export { buildExitSummaryText } from "./exit-summary"; diff --git a/src/ui/utils/index.ts b/src/ui/utils/index.ts index 4b498a06..b9b61ec4 100644 --- a/src/ui/utils/index.ts +++ b/src/ui/utils/index.ts @@ -1,7 +1,10 @@ import chalk from "chalk"; -import type { SessionMessage } from "../../session"; import { renderMessageToStdout } from "../components/MessageView/utils"; import type { RawMode } from "../contexts"; +import type { PromptDraft } from "../views/PromptInput"; +import type { ModelConfigSelection } from "../../settings"; +import type { SessionEntry, SessionMessage } from "../../session"; +import type { SessionManager } from "../../session"; /** * Render all messages directly to stdout for Raw mode display. @@ -22,3 +25,79 @@ export function renderRawModeMessages(allMessages: SessionMessage[], mode: strin process.stdout.write(chalk.dim("Press ESC to exit raw mode")); } } + +export function buildSyntheticUserMessage(content: string, imageCount: number): SessionMessage { + const now = new Date().toISOString(); + return { + id: `local-${Math.random().toString(36).slice(2)}`, + sessionId: "local", + role: "user", + content, + contentParams: + imageCount > 0 + ? Array.from({ length: imageCount }, () => ({ + type: "image_url", + image_url: { url: "" }, + })) + : null, + messageParams: null, + compacted: false, + visible: true, + createTime: now, + updateTime: now, + }; +} + +export function buildPromptDraftFromSessionMessage(message: SessionMessage, nonce: number): PromptDraft { + return { + nonce, + text: typeof message.content === "string" ? message.content : "", + imageUrls: extractImageUrlsFromContentParams(message.contentParams), + }; +} + +export function extractImageUrlsFromContentParams(contentParams: unknown): string[] { + const params = Array.isArray(contentParams) ? contentParams : contentParams ? [contentParams] : []; + const imageUrls: string[] = []; + for (const param of params) { + if (!param || typeof param !== "object") { + continue; + } + const record = param as { type?: unknown; image_url?: { url?: unknown } }; + const url = record.image_url?.url; + if (record.type === "image_url" && typeof url === "string" && url) { + imageUrls.push(url); + } + } + return imageUrls; +} + +export function isCurrentSessionEmpty(sessionManager: SessionManager): boolean { + const activeSessionId = sessionManager.getActiveSessionId(); + return !activeSessionId || !sessionManager.getSession(activeSessionId); +} + +export function buildStatusLine(entry: SessionEntry): string { + const parts: string[] = []; + parts.push(`status: ${entry.status}`); + if (typeof entry.activeTokens === "number" && entry.activeTokens > 0) { + parts.push(`tokens: ${entry.activeTokens}`); + } + if (entry.failReason) { + parts.push(`fail: ${entry.failReason}`); + } + return parts.join(" · "); +} + +export function formatThinkingMode( + settings: Pick +): string { + if (!settings.thinkingEnabled) { + return "no thinking"; + } + return `thinking ${settings.reasoningEffort}`; +} + +export function formatModelConfig(settings: ModelConfigSelection): string { + return `${settings.model}, ${formatThinkingMode(settings)}`; +} diff --git a/src/ui/App.tsx b/src/ui/views/App.tsx similarity index 80% rename from src/ui/App.tsx rename to src/ui/views/App.tsx index ae94fa06..bef803e3 100644 --- a/src/ui/App.tsx +++ b/src/ui/views/App.tsx @@ -1,35 +1,15 @@ import React, { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from "react"; import { Box, Static, Text, useApp, useStdout, useWindowSize } from "ink"; import chalk from "chalk"; -import * as fs from "fs"; -import * as os from "os"; -import * as path from "path"; -import { createOpenAIClient } from "../common/openai-client"; -import { - type LlmStreamProgress, - type MessageMeta, - type PermissionScope, - type SessionEntry, - SessionManager, - type SessionMessage, - type SessionStatus, - type SkillInfo, - type UndoTarget, - type UserPromptContent, -} from "../session"; -import { - applyModelConfigSelection, - type DeepcodingSettings, - type ModelConfigSelection, - type ResolvedDeepcodingSettings, - resolveSettingsSources, -} from "../settings"; -import { PromptInput, type PromptDraft, type PromptSubmission } from "./PromptInput"; -import { MessageView, RawModeExitPrompt } from "./components"; +import { createOpenAIClient } from "../../common/openai-client"; +import type { PermissionScope } from "../../settings"; +import { type ModelConfigSelection } from "../../settings"; +import { type PromptDraft, PromptInput, type PromptSubmission } from "./PromptInput"; +import { MessageView, RawModeExitPrompt } from "../components"; import { SessionList } from "./SessionList"; -import { UndoSelector, type UndoRestoreMode } from "./UndoSelector"; -import { buildLoadingText } from "./loadingText"; -import { findExpandedThinkingId } from "./thinkingState"; +import { type UndoRestoreMode, UndoSelector } from "./UndoSelector"; +import { buildLoadingText } from "../core/loading-text"; +import { findExpandedThinkingId } from "../core/thinking-state"; import { WelcomeScreen } from "./WelcomeScreen"; import { AskUserQuestionPrompt } from "./AskUserQuestionPrompt"; import { McpStatusList } from "./McpStatusList"; @@ -38,16 +18,33 @@ import { type AskUserQuestionAnswers, findPendingAskUserQuestion, formatAskUserQuestionAnswers, -} from "./askUserQuestion"; +} from "../core/ask-user-question"; import { PermissionPrompt, type PermissionPromptResult } from "./PermissionPrompt"; -import { buildExitSummaryText } from "./exitSummary"; -import { RawMode, useRawModeContext } from "./contexts"; -import { renderMessageToStdout } from "./components/MessageView/utils"; -import { renderRawModeMessages } from "./utils"; -import { ANSI_CLEAR_SCREEN } from "./constants"; - -const DEFAULT_MODEL = "deepseek-v4-pro"; -const DEFAULT_BASE_URL = "https://api.deepseek.com"; +import { buildExitSummaryText } from "../exit-summary"; +import { RawMode, useRawModeContext } from "../contexts"; +import { renderMessageToStdout } from "../components/MessageView/utils"; +import { + buildPromptDraftFromSessionMessage, + buildStatusLine, + buildSyntheticUserMessage, + formatModelConfig, + isCurrentSessionEmpty, + renderRawModeMessages, +} from "../utils"; +import { resolveCurrentSettings, writeModelConfigSelection } from "../../settings"; +import { isCollapsedThinking } from "../core/thinking-state"; +import { ANSI_CLEAR_SCREEN } from "../constants"; +import type { + LlmStreamProgress, + MessageMeta, + SessionEntry, + SessionMessage, + SessionStatus, + SkillInfo, + UndoTarget, + UserPromptContent, +} from "../../session"; +import { SessionManager } from "../../session"; type View = "chat" | "session-list" | "undo" | "mcp-status"; @@ -662,6 +659,7 @@ function App({ projectRoot, initialPrompt, onRestart }: AppProps): React.ReactEl if (!sessionId) { return; } + setPromptDraft(null); if (result.hasDeny) { setPendingPermissionReply({ sessionId, @@ -669,7 +667,6 @@ function App({ projectRoot, initialPrompt, onRestart }: AppProps): React.ReactEl alwaysAllows: result.alwaysAllows, }); setStatusLine("Permission denied. Add a reply, then press Enter to continue."); - setPromptDraft(null); sessionManager.denySessionPermission(sessionId); return; } @@ -754,8 +751,8 @@ function App({ projectRoot, initialPrompt, onRestart }: AppProps): React.ReactEl targets={undoTargets} onSelect={(target, restoreMode) => void handleUndoRestore(target, restoreMode)} onCancel={() => { + setPromptDraft(null); setView("chat"); - setShowWelcome(true); }} /> ) : view === "mcp-status" ? ( @@ -807,163 +804,3 @@ function App({ projectRoot, initialPrompt, onRestart }: AppProps): React.ReactEl } export default App; - -function isCollapsedThinking(message: SessionMessage, expandedId: string | null): boolean { - if (message.role !== "assistant") { - return false; - } - if (!message.meta?.asThinking) { - return false; - } - return message.id !== expandedId; -} - -function buildSyntheticUserMessage(content: string, imageCount: number): SessionMessage { - const now = new Date().toISOString(); - return { - id: `local-${Math.random().toString(36).slice(2)}`, - sessionId: "local", - role: "user", - content, - contentParams: - imageCount > 0 - ? Array.from({ length: imageCount }, () => ({ - type: "image_url", - image_url: { url: "" }, - })) - : null, - messageParams: null, - compacted: false, - visible: true, - createTime: now, - updateTime: now, - }; -} - -export function buildPromptDraftFromSessionMessage(message: SessionMessage, nonce: number): PromptDraft { - return { - nonce, - text: typeof message.content === "string" ? message.content : "", - imageUrls: extractImageUrlsFromContentParams(message.contentParams), - }; -} - -function extractImageUrlsFromContentParams(contentParams: unknown): string[] { - const params = Array.isArray(contentParams) ? contentParams : contentParams ? [contentParams] : []; - const imageUrls: string[] = []; - for (const param of params) { - if (!param || typeof param !== "object") { - continue; - } - const record = param as { type?: unknown; image_url?: { url?: unknown } }; - const url = record.image_url?.url; - if (record.type === "image_url" && typeof url === "string" && url) { - imageUrls.push(url); - } - } - return imageUrls; -} - -function isCurrentSessionEmpty(sessionManager: SessionManager): boolean { - const activeSessionId = sessionManager.getActiveSessionId(); - return !activeSessionId || !sessionManager.getSession(activeSessionId); -} - -function buildStatusLine(entry: SessionEntry): string { - const parts: string[] = []; - parts.push(`status: ${entry.status}`); - if (typeof entry.activeTokens === "number" && entry.activeTokens > 0) { - parts.push(`tokens: ${entry.activeTokens}`); - } - if (entry.failReason) { - parts.push(`fail: ${entry.failReason}`); - } - return parts.join(" · "); -} - -export function readSettings(): DeepcodingSettings | null { - return readSettingsFile(getUserSettingsPath()); -} - -export function readProjectSettings(projectRoot: string = process.cwd()): DeepcodingSettings | null { - return readSettingsFile(getProjectSettingsPath(projectRoot)); -} - -function readSettingsFile(settingsPath: string): DeepcodingSettings | null { - try { - if (!fs.existsSync(settingsPath)) { - return null; - } - const raw = fs.readFileSync(settingsPath, "utf8"); - return JSON.parse(raw) as DeepcodingSettings; - } catch { - return null; - } -} - -export function writeSettings(settings: DeepcodingSettings): void { - const settingsPath = getUserSettingsPath(); - writeSettingsFile(settingsPath, settings); -} - -export function writeProjectSettings(settings: DeepcodingSettings, projectRoot: string = process.cwd()): void { - const settingsPath = getProjectSettingsPath(projectRoot); - writeSettingsFile(settingsPath, settings); -} - -function writeSettingsFile(settingsPath: string, settings: DeepcodingSettings): void { - fs.mkdirSync(path.dirname(settingsPath), { recursive: true }); - fs.writeFileSync(settingsPath, `${JSON.stringify(settings, null, 2)}\n`, "utf8"); -} - -export function writeModelConfigSelection( - selection: ModelConfigSelection, - current: ModelConfigSelection = resolveCurrentSettings(), - projectRoot: string = process.cwd() -): { changed: boolean; settings: DeepcodingSettings } { - const projectSettingsPath = getProjectSettingsPath(projectRoot); - const shouldWriteProjectSettings = fs.existsSync(projectSettingsPath); - const rawSettings = shouldWriteProjectSettings ? readProjectSettings(projectRoot) : readSettings(); - const result = applyModelConfigSelection(rawSettings, current, selection); - if (result.changed) { - if (shouldWriteProjectSettings) { - writeProjectSettings(result.settings, projectRoot); - } else { - writeSettings(result.settings); - } - } - return result; -} - -export function resolveCurrentSettings(projectRoot: string = process.cwd()): ResolvedDeepcodingSettings { - return resolveSettingsSources( - readSettings(), - readProjectSettings(projectRoot), - { - model: DEFAULT_MODEL, - baseURL: DEFAULT_BASE_URL, - }, - process.env - ); -} - -export { createOpenAIClient } from "../common/openai-client"; - -function getUserSettingsPath(): string { - return path.join(os.homedir(), ".deepcode", "settings.json"); -} - -function getProjectSettingsPath(projectRoot: string): string { - return path.join(projectRoot, ".deepcode", "settings.json"); -} - -function formatThinkingMode(settings: Pick): string { - if (!settings.thinkingEnabled) { - return "no thinking"; - } - return `thinking ${settings.reasoningEffort}`; -} - -function formatModelConfig(settings: ModelConfigSelection): string { - return `${settings.model}, ${formatThinkingMode(settings)}`; -} diff --git a/src/ui/AppContainer.tsx b/src/ui/views/AppContainer.tsx similarity index 83% rename from src/ui/AppContainer.tsx rename to src/ui/views/AppContainer.tsx index c8b31773..d5f6363a 100644 --- a/src/ui/AppContainer.tsx +++ b/src/ui/views/AppContainer.tsx @@ -1,7 +1,7 @@ import React from "react"; -import { AppContext } from "./contexts"; +import { AppContext } from "../contexts"; import App from "./App"; -import { RawModeProvider } from "./contexts/RawModeContext"; +import { RawModeProvider } from "../contexts"; const AppContainer: React.FC<{ projectRoot: string; diff --git a/src/ui/AskUserQuestionPrompt.tsx b/src/ui/views/AskUserQuestionPrompt.tsx similarity index 99% rename from src/ui/AskUserQuestionPrompt.tsx rename to src/ui/views/AskUserQuestionPrompt.tsx index 7c76ae38..a2f91adb 100644 --- a/src/ui/AskUserQuestionPrompt.tsx +++ b/src/ui/views/AskUserQuestionPrompt.tsx @@ -1,7 +1,7 @@ import React, { useEffect, useMemo, useState } from "react"; import { Box, Text } from "ink"; -import type { AskUserQuestionAnswers, AskUserQuestionItem } from "./askUserQuestion"; -import { useTerminalInput } from "./PromptInput"; +import type { AskUserQuestionAnswers, AskUserQuestionItem } from "../core/ask-user-question"; +import { useTerminalInput } from "../hooks"; type Props = { questions: AskUserQuestionItem[]; diff --git a/src/ui/McpStatusList.tsx b/src/ui/views/McpStatusList.tsx similarity index 97% rename from src/ui/McpStatusList.tsx rename to src/ui/views/McpStatusList.tsx index 095612a2..40d2f3f4 100644 --- a/src/ui/McpStatusList.tsx +++ b/src/ui/views/McpStatusList.tsx @@ -1,6 +1,6 @@ import React, { useState, useMemo, useCallback } from "react"; import { Box, Text, useInput, useWindowSize } from "ink"; -import type { McpServerStatus } from "../mcp/mcp-manager"; +import type { McpServerStatus } from "../../mcp/mcp-manager"; type Props = { statuses: McpServerStatus[]; @@ -195,20 +195,10 @@ function ServerListView({ ( - - {readyCount} ready, - - - {startingCount} starting, - - {reconnectingCount > 0 && ( - - {reconnectingCount} reconnecting, - - )} - - {failedCount} failed - + {readyCount} ready, + {startingCount} starting, + {reconnectingCount > 0 && {reconnectingCount} reconnecting,} + {failedCount} failed ) diff --git a/src/ui/PermissionPrompt.tsx b/src/ui/views/PermissionPrompt.tsx similarity index 97% rename from src/ui/PermissionPrompt.tsx rename to src/ui/views/PermissionPrompt.tsx index 03881a58..320dd7ab 100644 --- a/src/ui/PermissionPrompt.tsx +++ b/src/ui/views/PermissionPrompt.tsx @@ -1,7 +1,8 @@ import React, { useEffect, useMemo, useState } from "react"; import { Box, Text } from "ink"; -import type { AskPermissionRequest, AskPermissionScope, PermissionScope, UserToolPermission } from "../session"; -import { useTerminalInput } from "./PromptInput"; +import { useTerminalInput } from "../hooks"; +import type { AskPermissionRequest, AskPermissionScope, UserToolPermission } from "../../common/permissions"; +import type { PermissionScope } from "../../settings"; export type PermissionPromptResult = { permissions: UserToolPermission[]; diff --git a/src/ui/ProcessStdoutView.tsx b/src/ui/views/ProcessStdoutView.tsx similarity index 97% rename from src/ui/ProcessStdoutView.tsx rename to src/ui/views/ProcessStdoutView.tsx index bc76a2f1..bd5e6363 100644 --- a/src/ui/ProcessStdoutView.tsx +++ b/src/ui/views/ProcessStdoutView.tsx @@ -1,13 +1,13 @@ import React, { useEffect, useMemo, useRef, useState } from "react"; import { Box, Text } from "ink"; -import { BASH_TIMEOUT_DECREMENT_MS, BASH_TIMEOUT_INCREMENT_MS } from "../common/bash-timeout"; -import type { BashTimeoutAdjustment, SessionEntry, SessionProcessEntry } from "../session"; -import { useTerminalInput } from "./prompt"; +import { BASH_TIMEOUT_DECREMENT_MS, BASH_TIMEOUT_INCREMENT_MS } from "../../common/bash-timeout"; +import type { BashTimeoutAdjustment, SessionEntry, SessionProcessEntry } from "../../session"; +import { useTerminalInput } from "../hooks"; type RunningProcesses = SessionEntry["processes"]; type ProcessStdoutViewProps = { - processStdoutRef: React.MutableRefObject>; + processStdoutRef: React.RefObject>; runningProcesses: RunningProcesses; onDismiss: () => void; onAdjustTimeout: (deltaMs: number) => BashTimeoutAdjustment | null; diff --git a/src/ui/PromptInput.tsx b/src/ui/views/PromptInput.tsx similarity index 83% rename from src/ui/PromptInput.tsx rename to src/ui/views/PromptInput.tsx index 8c808e9e..b812a73d 100644 --- a/src/ui/PromptInput.tsx +++ b/src/ui/views/PromptInput.tsx @@ -1,21 +1,18 @@ import React, { useEffect, useMemo, useState } from "react"; import { Box, Text, useApp, useStdout } from "ink"; import chalk from "chalk"; -import { ARGS_SEPARATOR } from "./constants"; +import { ARGS_SEPARATOR } from "../constants"; import { EMPTY_BUFFER, PASTE_MARKER_REGEX, backspace, - cleanPasteContent, deleteForward, deletePasteMarkerBackward, deletePasteMarkerForward, deleteWordBefore, deleteWordAfter, expandPasteMarkers, - findPasteMarkerContaining, getCurrentSlashToken, - hasActivePasteMarkers, insertText, isEmpty, killLine, @@ -27,42 +24,38 @@ import { moveWordLeft, moveWordRight, moveUp, -} from "./promptBuffer"; -import type { PromptBufferState } from "./promptBuffer"; +} from "../core/prompt-buffer"; +import type { PromptBufferState } from "../core/prompt-buffer"; import { clearPromptUndoRedoState, createPromptUndoRedoState, recordPromptEdit, redoPromptEdit, undoPromptEdit, -} from "./promptUndoRedo"; -import { buildSlashCommands, filterSlashCommands, findExactSlashCommand } from "./slashCommands"; -import type { SlashCommandItem } from "./slashCommands"; +} from "../core/prompt-undo-redo"; +import { buildSlashCommands, filterSlashCommands, findExactSlashCommand } from "../core/slash-commands"; +import type { SlashCommandItem } from "../core/slash-commands"; import { filterFileMentionItems, getCurrentFileMentionToken, replaceCurrentFileMentionToken, scanFileMentionItems, -} from "./fileMentions"; -import type { FileMentionItem } from "./fileMentions"; -import { readClipboardImageAsync } from "./clipboard"; -import type { PermissionScope, SessionEntry, SkillInfo, UserToolPermission } from "../session"; - -// Re-exported from prompt modules for backward compatibility -export { useTerminalInput, parseTerminalInput, dispatchTerminalInput } from "./prompt"; -export type { InputKey } from "./prompt"; - -import { useTerminalInput } from "./prompt"; -import type { InputKey } from "./prompt"; +} from "../core/file-mentions"; +import type { FileMentionItem } from "../core/file-mentions"; +import { readClipboardImageAsync } from "../core/clipboard"; +import { useTerminalInput, usePasteHandling, useHistoryNavigation } from "../hooks"; +import type { InputKey } from "../hooks"; import { useHiddenTerminalCursor, useTerminalExtendedKeys, useBracketedPaste, useTerminalFocusReporting, -} from "./prompt"; +} from "../hooks"; import SlashCommandMenu, { isSkillSelected } from "./SlashCommandMenu"; -import type { ModelConfigSelection } from "../settings"; -import { FileMentionMenu, ModelsDropdown, RawModelDropdown, SkillsDropdown } from "./components"; +import type { ModelConfigSelection, PermissionScope } from "../../settings"; +import { FileMentionMenu, ModelsDropdown, RawModelDropdown, SkillsDropdown } from "../components"; +import type { SessionEntry, SkillInfo } from "../../session"; +import type { UserToolPermission } from "../../common/permissions"; export type PromptSubmission = { text: string; @@ -149,21 +142,22 @@ export const PromptInput = React.memo(function PromptInput({ const [showModelDropdown, setShowModelDropdown] = useState(false); const [fileMentionItems, setFileMentionItems] = useState(() => scanFileMentionItems(projectRoot)); const [dismissedFileMentionKey, setDismissedFileMentionKey] = useState(null); - const [historyCursor, setHistoryCursor] = useState(-1); - const [draftBeforeHistory, setDraftBeforeHistory] = useState(null); const [hasTerminalFocus, setHasTerminalFocus] = useState(true); const lastCtrlDAt = React.useRef(0); const undoRedoRef = React.useRef(createPromptUndoRedoState()); const wasBusyRef = React.useRef(busy); const hadFileMentionTokenRef = React.useRef(false); const appliedDraftNonceRef = React.useRef(null); - const pastesRef = React.useRef>(new Map()); - const pasteCounterRef = React.useRef(0); - // Track expanded paste regions for toggle (Ctrl+O expand / collapse). - const expandedRegionsRef = React.useRef>( - new Map() + + const { historyCursor, navigateHistory, exitHistoryBrowsing } = useHistoryNavigation( + buffer, + setBuffer, + promptHistory ); + const { pastesRef, handlePaste, expandPasteMarkerAtCursor, resetPastes, hasCollapsedMarkers, hasExpandedRegions } = + usePasteHandling(buffer, updateBuffer, setStatusMessage); + const fileMentionToken = getCurrentFileMentionToken(buffer); const hasFileMentionToken = fileMentionToken !== null; const fileMentionKey = fileMentionToken ? `${fileMentionToken.start}:${fileMentionToken.query}` : null; @@ -190,8 +184,6 @@ export const PromptInput = React.memo(function PromptInput({ const showMenu = slashMenu.length > 0; const promptHistoryKey = React.useMemo(() => promptHistory.join("\0"), [promptHistory]); const hasRunningProcess = runningProcesses && runningProcesses.size > 0; - const hasCollapsedMarkers = hasActivePasteMarkers(buffer.text, pastesRef.current); - const hasExpandedRegions = expandedRegionsRef.current.size > 0; const processOrPasteHint = hasRunningProcess ? " · ctrl+o view output" : hasCollapsedMarkers @@ -267,17 +259,14 @@ export const PromptInput = React.memo(function PromptInput({ setSelectedSkills([]); setShowSkillsDropdown(false); setOpenRawModelDropdown(false); - setHistoryCursor(-1); - setDraftBeforeHistory(null); + exitHistoryBrowsing(); clearPromptUndoRedoState(undoRedoRef.current); - pastesRef.current.clear(); - expandedRegionsRef.current.clear(); - }, [promptDraft]); + resetPastes(); + }, [promptDraft, exitHistoryBrowsing, resetPastes]); useEffect(() => { - setHistoryCursor(-1); - setDraftBeforeHistory(null); - }, [promptHistoryKey]); + exitHistoryBrowsing(); + }, [promptHistoryKey, exitHistoryBrowsing]); useTerminalInput( (input, key) => { @@ -337,8 +326,7 @@ export const PromptInput = React.memo(function PromptInput({ } else if (!isEmpty(buffer)) { setBuffer(EMPTY_BUFFER); clearUndoRedoStacks(); - pastesRef.current.clear(); - expandedRegionsRef.current.clear(); + resetPastes(); } else { setStatusMessage("press ctrl+d to exit"); } @@ -528,8 +516,7 @@ export const PromptInput = React.memo(function PromptInput({ } if (key.ctrl && (input === "u" || input === "U")) { updateBuffer(() => EMPTY_BUFFER); - pastesRef.current.clear(); - expandedRegionsRef.current.clear(); + resetPastes(); return; } if (key.ctrl && (input === "w" || input === "W")) { @@ -593,11 +580,6 @@ export const PromptInput = React.memo(function PromptInput({ clearPromptUndoRedoState(undoRedoRef.current); } - function exitHistoryBrowsing(): void { - setHistoryCursor(-1); - setDraftBeforeHistory(null); - } - function updateBuffer(updater: (state: PromptBufferState) => PromptBufferState): void { exitHistoryBrowsing(); setBuffer((current) => { @@ -607,107 +589,6 @@ export const PromptInput = React.memo(function PromptInput({ }); } - function handlePaste(pastedText: string): void { - const totalChars = pastedText.length; - - if (totalChars <= 1000) { - const newlineCount = (pastedText.match(/\n/g) ?? []).length; - if (newlineCount <= 9) { - const clean = pastedText - .replace(/\r\n|\r/g, "\n") - .replace(/[\x00-\x08\x0b\x0c\x0e-\x1f\x7f]/g, "") - .replace(/\t/g, " "); - updateBuffer((s) => insertText(s, clean)); - return; - } - } - - // Large paste: store raw text, insert marker with line/char count. - const lineCount = (pastedText.match(/\n/g) ?? []).length + 1; - pasteCounterRef.current += 1; - const pasteId = pasteCounterRef.current; - pastesRef.current.set(pasteId, pastedText); - - const marker = - lineCount > 10 ? `[paste #${pasteId} +${lineCount} lines]` : `[paste #${pasteId} ${totalChars} chars]`; - - updateBuffer((s) => insertText(s, marker)); - } - - function expandPasteMarkerAtCursor(): void { - // First, try to collapse an already-expanded region at the cursor. - for (const [id, region] of expandedRegionsRef.current) { - if (buffer.cursor >= region.start && buffer.cursor <= region.end) { - // Collapse back to marker. - expandedRegionsRef.current.delete(id); - pastesRef.current.set(id, region.content); - setTimeout(() => { - updateBuffer((s) => { - const text = s.text.slice(0, region.start) + region.marker + s.text.slice(region.end); - return { text, cursor: region.start + region.marker.length }; - }); - }, 0); - return; - } - } - - // No expanded region at cursor — try to expand a paste marker. - const marker = findPasteMarkerContaining(buffer); - if (!marker) { - setStatusMessage("No paste marker at cursor"); - return; - } - const content = pastesRef.current.get(marker.id); - if (!content) { - setStatusMessage("Paste content not found"); - return; - } - - const pasteId = marker.id; - const originalMarker = buffer.text.slice(marker.start, marker.end); - pastesRef.current.delete(pasteId); - - setTimeout(() => { - updateBuffer((s) => { - const text = s.text.slice(0, marker.start) + cleanPasteContent(content) + s.text.slice(marker.end); - const newEnd = marker.start + content.length; - expandedRegionsRef.current.set(pasteId, { - start: marker.start, - end: newEnd, - content, - marker: originalMarker, - }); - return { text, cursor: marker.start }; - }); - }, 0); - } - - function navigateHistory(direction: -1 | 1): void { - if (promptHistory.length === 0) { - return; - } - - const previousCursor = historyCursor === -1 ? promptHistory.length : historyCursor; - const nextCursor = Math.max(0, Math.min(promptHistory.length, previousCursor + direction)); - const draft = historyCursor === -1 ? buffer.text : draftBeforeHistory; - - if (historyCursor === -1) { - setDraftBeforeHistory(buffer.text); - } - - if (nextCursor === promptHistory.length) { - const text = draft ?? ""; - setBuffer({ text, cursor: text.length }); - setHistoryCursor(-1); - setDraftBeforeHistory(null); - return; - } - - const text = promptHistory[nextCursor] ?? ""; - setBuffer({ text, cursor: text.length }); - setHistoryCursor(nextCursor); - } - function insertFileMentionSelection(item: FileMentionItem): void { if (!fileMentionToken) { return; @@ -722,9 +603,8 @@ export const PromptInput = React.memo(function PromptInput({ setImageUrls([]); setSelectedSkills([]); setShowSkillsDropdown(false); - pastesRef.current.clear(); - expandedRegionsRef.current.clear(); - pasteCounterRef.current = 0; + exitHistoryBrowsing(); + resetPastes(); } function handleSlashSelection(item: SlashCommandItem): void { diff --git a/src/ui/SessionList.tsx b/src/ui/views/SessionList.tsx similarity index 98% rename from src/ui/SessionList.tsx rename to src/ui/views/SessionList.tsx index 2d83b847..ac53f218 100644 --- a/src/ui/SessionList.tsx +++ b/src/ui/views/SessionList.tsx @@ -1,7 +1,7 @@ import React, { useState, useMemo, useCallback } from "react"; import { Box, Text, useInput, useWindowSize } from "ink"; -import type { SessionEntry, SessionStatus } from "../session"; -import { truncate } from "./components/MessageView/utils"; +import type { SessionEntry, SessionStatus } from "../../session"; +import { truncate } from "../components/MessageView/utils"; type Props = { sessions: SessionEntry[]; diff --git a/src/ui/SlashCommandMenu.tsx b/src/ui/views/SlashCommandMenu.tsx similarity index 93% rename from src/ui/SlashCommandMenu.tsx rename to src/ui/views/SlashCommandMenu.tsx index df599b54..d93446de 100644 --- a/src/ui/SlashCommandMenu.tsx +++ b/src/ui/views/SlashCommandMenu.tsx @@ -1,9 +1,9 @@ -import { formatSlashCommandDescription, formatSlashCommandLabel } from "./slashCommands"; -import type { SlashCommandItem } from "./slashCommands"; -import { ARGS_SEPARATOR } from "./constants"; +import { formatSlashCommandDescription, formatSlashCommandLabel } from "../core/slash-commands"; +import type { SlashCommandItem } from "../core/slash-commands"; +import { ARGS_SEPARATOR } from "../constants"; import React from "react"; import { Box, Text } from "ink"; -import type { SkillInfo } from "../session"; +import type { SkillInfo } from "../../session"; type SlashCommandMenuProps = { items: SlashCommandItem[]; diff --git a/src/ui/ThemedGradient.tsx b/src/ui/views/ThemedGradient.tsx similarity index 100% rename from src/ui/ThemedGradient.tsx rename to src/ui/views/ThemedGradient.tsx diff --git a/src/ui/UndoSelector.tsx b/src/ui/views/UndoSelector.tsx similarity index 98% rename from src/ui/UndoSelector.tsx rename to src/ui/views/UndoSelector.tsx index fad3e178..977bca26 100644 --- a/src/ui/UndoSelector.tsx +++ b/src/ui/views/UndoSelector.tsx @@ -1,6 +1,6 @@ import React, { useMemo, useState } from "react"; import { Box, Text, useInput, useWindowSize } from "ink"; -import type { UndoTarget } from "../session"; +import type { UndoTarget } from "../../session"; export type UndoRestoreMode = "code-and-conversation" | "conversation"; @@ -99,7 +99,7 @@ export function UndoSelector({ targets, onSelect, onCancel }: Props): React.Reac > - + Undo restore to the point before a prompt diff --git a/src/ui/UpdatePrompt.tsx b/src/ui/views/UpdatePrompt.tsx similarity index 100% rename from src/ui/UpdatePrompt.tsx rename to src/ui/views/UpdatePrompt.tsx diff --git a/src/ui/WelcomeScreen.tsx b/src/ui/views/WelcomeScreen.tsx similarity index 94% rename from src/ui/WelcomeScreen.tsx rename to src/ui/views/WelcomeScreen.tsx index 7e740d1f..96aef71f 100644 --- a/src/ui/WelcomeScreen.tsx +++ b/src/ui/views/WelcomeScreen.tsx @@ -2,12 +2,12 @@ import React, { useMemo, useState } from "react"; import { Box, Text } from "ink"; import * as os from "node:os"; import path from "node:path"; -import type { SkillInfo } from "../session"; -import type { ResolvedDeepcodingSettings } from "../settings"; -import { buildSlashCommands, BUILTIN_SLASH_COMMANDS, formatSlashCommandDescription } from "./slashCommands"; +import type { SkillInfo } from "../../session"; +import type { ResolvedDeepcodingSettings } from "../../settings"; +import { buildSlashCommands, BUILTIN_SLASH_COMMANDS, formatSlashCommandDescription } from "../core/slash-commands"; import { ThemedGradient } from "./ThemedGradient"; -import { AsciiLogo } from "../AsciiArt"; -import { useAppContext } from "./contexts"; +import { AsciiLogo } from "../ascii-art"; +import { useAppContext } from "../contexts"; type WelcomeScreenProps = { projectRoot: string;