diff --git a/apps/desktop/src-tauri/Cargo.lock b/apps/desktop/src-tauri/Cargo.lock index 4198971..9e7aacd 100644 --- a/apps/desktop/src-tauri/Cargo.lock +++ b/apps/desktop/src-tauri/Cargo.lock @@ -672,7 +672,7 @@ dependencies = [ [[package]] name = "deepcode_desktop" -version = "0.1.1" +version = "0.1.2" dependencies = [ "dirs 5.0.1", "serde", diff --git a/apps/desktop/src/App.tsx b/apps/desktop/src/App.tsx index 3012c99..6f4a294 100644 --- a/apps/desktop/src/App.tsx +++ b/apps/desktop/src/App.tsx @@ -125,49 +125,23 @@ function renderScreen( return ; case 'sessions': return ( -
- setScreen('repl')} - onNew={() => setScreen('repl')} - /> -
+ setScreen('repl')} + onNew={() => setScreen('repl')} + /> ); case 'plugins': - return ( -
- -
- ); + return ; case 'skills': - return ( -
- -
- ); + return ; case 'permissions': - return ( -
- -
- ); + return ; case 'mcp': - return ( -
- -
- ); + return ; case 'settings': - return ( -
- -
- ); + return ; case 'about': - return ( -
- -
- ); + return ; case 'repl': default: return ; diff --git a/apps/desktop/src/components/Screen.tsx b/apps/desktop/src/components/Screen.tsx new file mode 100644 index 0000000..d75af5f --- /dev/null +++ b/apps/desktop/src/components/Screen.tsx @@ -0,0 +1,163 @@ +// Generic utility-screen shell. +// Used by Sessions / Plugins / Skills / Permissions / MCP / Settings / +// About — each one is a vertical scroll surface with a header (title + +// optional subtitle + optional action) and a body containing Cards. +// +// Matches the design language: chat-main column, padded body, header +// pinned at top. No 3-column shell — that's the App-level grid. + +import type { ReactNode } from 'react'; + +interface ScreenProps { + title: string; + subtitle?: string; + /** Right-aligned header actions — usually one or two buttons. */ + actions?: ReactNode; + children: ReactNode; +} + +export function Screen({ title, subtitle, actions, children }: ScreenProps): JSX.Element { + return ( + <> +
+ + {title} + {subtitle && ( + <> + {' · '} + {subtitle} + + )} + + {actions &&
{actions}
} +
+
+ {children} +
+ + ); +} + +interface CardProps { + /** Optional title shown above the card body. */ + title?: string; + /** Optional right-aligned controls (button group, badge, ...). */ + actions?: ReactNode; + /** Body — usually a list, table, or form. */ + children: ReactNode; + /** Inner padding override. Default 16. */ + padding?: number; + /** If set, body has no top padding so a table sits flush with the head. */ + flush?: boolean; +} + +export function Card({ + title, + actions, + children, + padding = 16, + flush, +}: CardProps): JSX.Element { + return ( +
+ {(title || actions) && ( +
+ {title && ( +
+ {title} +
+ )} + {actions && ( +
+ {actions} +
+ )} +
+ )} +
{children}
+
+ ); +} + +/** Two-column row commonly seen in About / Settings (label → value). */ +interface RowProps { + label: string; + children: ReactNode; + /** Optional secondary helper text under the label. */ + hint?: string; +} + +export function Row({ label, hint, children }: RowProps): JSX.Element { + return ( +
+
+
{label}
+ {hint && ( +
+ {hint} +
+ )} +
+
+ {children} +
+
+ ); +} + +/** Section divider for grouping rows inside a card. */ +export function SectionTitle({ children }: { children: ReactNode }): JSX.Element { + return ( +
+ {children} +
+ ); +} diff --git a/apps/desktop/src/screens/About.tsx b/apps/desktop/src/screens/About.tsx index 49076b0..fd9150a 100644 --- a/apps/desktop/src/screens/About.tsx +++ b/apps/desktop/src/screens/About.tsx @@ -1,75 +1,165 @@ -// About screen — version + diagnostics + links. -// Spec: docs/VISUAL_DESIGN.html (about / doctor) -// Milestone: M6-rest +// About screen — design-aligned per docs/VISUAL_DESIGN.html. +// Brand mark + version + diagnostics + docs links. import { useEffect, useState } from 'react'; +import { BrandMark } from '../components/BrandMark.js'; +import { Card, Row, Screen, SectionTitle } from '../components/Screen.js'; +import { loadProjectPath } from '../lib/project.js'; +import { openUrl } from '../lib/tauri-api.js'; interface Diag { version: string; hasCreds: boolean; baseURL?: string; + projectPath?: string; } export function AboutScreen(): JSX.Element { const [diag, setDiag] = useState(null); useEffect(() => { - void Promise.all([window.deepcode.version(), window.deepcode.creds.load()]).then( - ([version, c]) => setDiag({ version, hasCreds: c.hasKey, baseURL: c.baseURL }), - ); + void (async () => { + const [version, creds, projectPath] = await Promise.all([ + window.deepcode.version(), + window.deepcode.creds.load(), + loadProjectPath(), + ]); + setDiag({ + version, + hasCreds: creds.hasKey, + baseURL: creds.baseURL, + projectPath, + }); + })(); }, []); - if (diag === null) return
Loading…
; + if (diag === null) { + return ( + +
Loading…
+
+ ); + } return ( -
-
-
-

DeepCode

-

+ +

+ {/* Brand block */} +
+
+ +
+

+ DeepCode +

+

DeepSeek-powered AI coding agent · Claude Code parity

+

+ v{diag.version} +

-
- - - - - - -
+ {/* Diagnostics */} + + Status + + {diag.hasCreds ? ( + ✓ configured + ) : ( + ✗ not configured + )} + + + {diag.projectPath ? ( + {diag.projectPath} + ) : ( + none picked + )} + + + + {diag.baseURL ?? 'https://api.deepseek.com/v1'} + + - -
-
- ); -} + Paths + + + ~/.deepcode/credentials.json + + + + + ~/.deepcode/settings.json + + + + + ~/.deepcode/sessions/ + + + + + ~/.deepcode/keybindings.json + + + -function Row({ label, value }: { label: string; value: string }): JSX.Element { - return ( -
-
{label}
-
{value}
-
+ {/* Links */} + +
+ {[ + ['github.com/oratis/deepcode', 'https://github.com/oratis/deepcode'], + [ + 'Security model', + 'https://github.com/oratis/deepcode/blob/main/docs/security-model.md', + ], + [ + 'Behavior parity vs Claude Code', + 'https://github.com/oratis/deepcode/blob/main/docs/BEHAVIOR_PARITY.md', + ], + [ + 'CHANGELOG', + 'https://github.com/oratis/deepcode/blob/main/CHANGELOG.md', + ], + ].map(([label, href]) => ( + { + e.preventDefault(); + void openUrl(href!); + }} + style={{ + color: '#b4c2ff', + fontSize: 13, + padding: '6px 0', + }} + > + {label} → + + ))} +
+
+
+ ); } diff --git a/apps/desktop/src/screens/MCPManager.tsx b/apps/desktop/src/screens/MCPManager.tsx index 4cb8fdf..21bcf26 100644 --- a/apps/desktop/src/screens/MCPManager.tsx +++ b/apps/desktop/src/screens/MCPManager.tsx @@ -1,8 +1,9 @@ -// MCP server manager screen — list / connect / test MCP servers. -// Spec: docs/VISUAL_DESIGN.html screen #7 -// Milestone: M6-rest +// MCP server manager — design-aligned per spec screen #15. +// List / show status of MCP servers wired in settings.json#mcpServers. import { useEffect, useState } from 'react'; +import { Badge, type BadgeKind } from '../components/Badge.js'; +import { Card, Screen, SectionTitle } from '../components/Screen.js'; interface McpServerStatus { name: string; @@ -11,6 +12,24 @@ interface McpServerStatus { error?: string; } +const STATUS_BADGE: Record< + McpServerStatus['status'], + { kind: BadgeKind; label: string } +> = { + connected: { kind: 'ok', label: '● connected' }, + failed: { kind: 'err', label: '✕ failed' }, + disabled: { kind: 'warn', label: '○ disabled' }, +}; + +const EXAMPLE_JSON = `{ + "mcpServers": { + "filesystem": { + "command": "npx", + "args": ["@modelcontextprotocol/server-filesystem", "/tmp"] + } + } +}`; + export function MCPManagerScreen(): JSX.Element { const [servers, setServers] = useState(null); @@ -26,67 +45,130 @@ export function MCPManagerScreen(): JSX.Element { }, []); if (servers === null) { - return
Loading MCP servers…
; + return ( + +
Loading…
+
+ ); } + const connected = servers.filter((s) => s.status === 'connected').length; + return ( -
-
-

MCP Servers

-

- Connected via settings.json > mcpServers.{' '} - {servers.length === 0 - ? 'None configured.' - : `${servers.filter((s) => s.status === 'connected').length} of ${servers.length} connected.`} -

-
-
- {servers.length === 0 ? ( -
-

No MCP servers configured.

-
-              {`{
-  "mcpServers": {
-    "filesystem": {
-      "command": "npx",
-      "args": ["@modelcontextprotocol/server-filesystem", "/tmp"]
-    }
-  }
-}`}
-            
-

- Add the snippet above to your settings.json then relaunch. -

-
- ) : ( -
    - {servers.map((s) => ( -
  • -
    - {s.name} - +
    + + {servers.length === 0 ? ( +
    + No MCP servers configured. +
    + Add the snippet below to ~/.deepcode/settings.json{' '} + and relaunch DeepCode. +
    +
    + ) : ( +
      + {servers.map((s, i) => { + const badge = STATUS_BADGE[s.status]; + return ( +
    • - {s.status} - {s.toolCount !== undefined && s.status === 'connected' - ? ` · ${s.toolCount} tools` - : ''} - -
    - {s.error && ( -
    {s.error}
    - )} -
  • - ))} -
- )} +
+ + {s.name} + + {badge.label} + {s.toolCount !== undefined && s.status === 'connected' && ( + + {s.toolCount} tools + + )} +
+ {s.error && ( +
+ {s.error} +
+ )} + + ); + })} + + )} + + + +
+            {EXAMPLE_JSON}
+          
+
+ + About MCP +
+ Model Context Protocol servers expose tools, resources, and prompts + that DeepCode can route into via JSON-RPC. Failures show their + stderr inline — most issues are a missing binary on $PATH or an + arg typo. +
-
+ ); } diff --git a/apps/desktop/src/screens/Permissions.tsx b/apps/desktop/src/screens/Permissions.tsx index 783d90a..3f59fdf 100644 --- a/apps/desktop/src/screens/Permissions.tsx +++ b/apps/desktop/src/screens/Permissions.tsx @@ -1,8 +1,11 @@ -// Permissions screen — view + edit settings.permissions rules. -// Spec: docs/VISUAL_DESIGN.html (permissions tab) · M3 permission rules -// Milestone: M6-rest +// Permissions screen — design-aligned. Per spec screen #10. +// View + edit settings.permissions rules. Save now actually persists +// to ~/.deepcode/settings.json via saveSettingsFile. import { useEffect, useState } from 'react'; +import { Badge, type BadgeKind } from '../components/Badge.js'; +import { Card, Row, Screen, SectionTitle } from '../components/Screen.js'; +import { loadSettingsFile, saveSettingsFile } from '../lib/tauri-api.js'; type RuleType = 'allow' | 'ask' | 'deny'; @@ -14,112 +17,286 @@ interface PermissionsView { additionalDirectories: string[]; } +const RULE_BADGE: Record = { + allow: { kind: 'ok', label: 'allow' }, + ask: { kind: 'warn', label: 'ask' }, + deny: { kind: 'err', label: 'deny' }, +}; + export function PermissionsScreen(): JSX.Element { const [perm, setPerm] = useState(null); - const [newRule, setNewRule] = useState({ type: 'allow' as RuleType, pattern: '' }); + const [newRule, setNewRule] = useState({ + type: 'allow' as RuleType, + pattern: '', + }); + const [saving, setSaving] = useState(false); + const [saveMsg, setSaveMsg] = useState(null); useEffect(() => { - void window.deepcode.settings.load().then((settings) => { - const p = (settings.permissions as PermissionsView | undefined) ?? { - defaultMode: 'default', - allow: [], - ask: [], - deny: [], - additionalDirectories: [], - }; - setPerm({ - defaultMode: p.defaultMode ?? 'default', - allow: p.allow ?? [], - ask: p.ask ?? [], - deny: p.deny ?? [], - additionalDirectories: p.additionalDirectories ?? [], - }); - }); + void (async () => { + try { + const settings = (await loadSettingsFile()) as Record; + const p = (settings.permissions as Partial | undefined) ?? {}; + setPerm({ + defaultMode: p.defaultMode ?? 'default', + allow: p.allow ?? [], + ask: p.ask ?? [], + deny: p.deny ?? [], + additionalDirectories: p.additionalDirectories ?? [], + }); + } catch { + setPerm({ + defaultMode: 'default', + allow: [], + ask: [], + deny: [], + additionalDirectories: [], + }); + } + })(); }, []); - if (perm === null) return
Loading permissions…
; - function addRule(): void { if (!newRule.pattern.trim()) return; setPerm((p) => - p ? { ...p, [newRule.type]: [...p[newRule.type], newRule.pattern.trim()] } : p, + p + ? { + ...p, + [newRule.type]: [...p[newRule.type], newRule.pattern.trim()], + } + : p, ); setNewRule({ type: 'allow', pattern: '' }); + setSaveMsg(null); + } + + function removeRule(type: RuleType, idx: number): void { + setPerm((p) => + p ? { ...p, [type]: p[type].filter((_, i) => i !== idx) } : p, + ); + setSaveMsg(null); + } + + async function handleSave(): Promise { + if (!perm) return; + setSaving(true); + setSaveMsg(null); + try { + const current = (await loadSettingsFile()) as Record; + await saveSettingsFile({ + ...current, + permissions: { + defaultMode: perm.defaultMode, + allow: perm.allow, + ask: perm.ask, + deny: perm.deny, + additionalDirectories: perm.additionalDirectories, + }, + }); + setSaveMsg('✓ Saved to ~/.deepcode/settings.json'); + } catch (err) { + setSaveMsg(`✕ Failed to save: ${(err as Error).message}`); + } finally { + setSaving(false); + } + } + + if (perm === null) { + return ( + +
Loading…
+
+ ); } return ( -
-
-

Permissions

-

- Default mode: {perm.defaultMode}. Precedence: deny > ask > allow. -

-
- -
-
- - setNewRule({ ...newRule, pattern: e.target.value })} - placeholder='e.g. Bash(npm test:*) or Read(./src/**)' - className="flex-1 rounded border border-border bg-bg px-3 py-2 outline-none focus:border-accent" - /> - +
+
- Add - -
-

- Save isn't wired yet — view-only preview for M6-rest. -

-
+ Pattern syntax: bare tool name (Bash) · subcommand + (Bash(git:*)) · prefix (Read(/etc/*)) · + domain (WebFetch(domain:github.com)). +
+ + + {(['deny', 'ask', 'allow'] as const).map((kind) => { + const badge = RULE_BADGE[kind]; + const rules = perm[kind]; + return ( + {badge.label}} + flush + padding={0} + > + {rules.length === 0 ? ( +
+ no rules +
+ ) : ( +
    + {rules.map((p, i) => ( +
  • + {p} + +
  • + ))} +
+ )} +
+ ); + })} -
- {(['deny', 'ask', 'allow'] as const).map((kind) => ( -
-

- {kind.toUpperCase()} · {perm[kind].length} -

- {perm[kind].length === 0 ? ( -
(none)
- ) : ( -
    - {perm[kind].map((p, i) => ( -
  • - {p} -
  • - ))} -
- )} -
- ))} {perm.additionalDirectories.length > 0 && ( -
-

- Additional Directories -

-
    + +
      {perm.additionalDirectories.map((d, i) => ( -
    • +
    • {d}
    • ))}
    -
+ )} + + Notes +
+ Inline "Always allow" buttons in the chat (over a pending tool + card) also write to this list. Removing a rule here disables it + immediately for the next tool call. +
-
+ ); } diff --git a/apps/desktop/src/screens/Plugins.tsx b/apps/desktop/src/screens/Plugins.tsx index 8a34b77..92dfeac 100644 --- a/apps/desktop/src/screens/Plugins.tsx +++ b/apps/desktop/src/screens/Plugins.tsx @@ -1,9 +1,11 @@ -// Plugins screen — list installed plugins, view trust state + contributed -// hooks, enable/disable, install new ones. -// Spec: docs/VISUAL_DESIGN.html screen #9 -// Milestone: M6-rest +// Plugins screen — design-aligned. Per spec screen #12. +// List installed plugins, their trust state + contributed hooks, +// enable/disable, install new ones. Install IPC still stubbed (P3 +// will wire installFromSpec). import { useEffect, useState } from 'react'; +import { Badge, type BadgeKind } from '../components/Badge.js'; +import { Card, Screen } from '../components/Screen.js'; interface PluginRow { name: string; @@ -15,10 +17,17 @@ interface PluginRow { warning?: string; } +const TRUST_BADGE: Record = { + official: { kind: 'ok', label: 'official' }, + marketplace: { kind: 'info', label: 'marketplace' }, + user: { kind: 'warn', label: 'user-installed' }, +}; + export function PluginsScreen(): JSX.Element { const [plugins, setPlugins] = useState(null); const [installSpec, setInstallSpec] = useState(''); const [installing, setInstalling] = useState(false); + const [feedback, setFeedback] = useState(null); useEffect(() => { if (window.deepcode?.plugins?.list) { @@ -34,102 +43,219 @@ export function PluginsScreen(): JSX.Element { async function handleInstall(): Promise { if (!installSpec.trim()) return; setInstalling(true); + setFeedback(null); try { - // window.deepcode.plugins.install(installSpec) — TODO wire in IPC PR - // For now no-op. + // Plugin install via Tauri is still not wired — surface that clearly. await new Promise((r) => setTimeout(r, 400)); + setFeedback( + 'Plugin install from the desktop UI is coming in v0.2. For now, install via the CLI: `deepcode plugin install ' + + installSpec.trim() + + '`', + ); } finally { setInstalling(false); - setInstallSpec(''); } } if (plugins === null) { - return
Loading plugins…
; + return ( + +
Loading…
+
+ ); } - return ( -
-
-

Plugins

-

- {plugins.length} installed ·{' '} - {plugins.filter((p) => p.enabled).length} enabled ·{' '} - ~/.deepcode/plugins/ -

-
- -
-
- setInstallSpec(e.target.value)} - placeholder="gh:user/repo · @npm · /local/path" - className="flex-1 rounded border border-border bg-bg px-3 py-2 text-fg outline-none focus:border-accent" - /> - -
-
+ const enabled = plugins.filter((p) => p.enabled).length; -
- {plugins.length === 0 ? ( -
-

No plugins installed.

-

- Try gh:owner/repo in the box above. -

+ return ( + +
+ +
+ setInstallSpec(e.target.value)} + placeholder="gh:user/repo · @npm · /local/path" + className="input" + style={{ flex: 1 }} + /> +
- ) : ( -
    - {plugins.map((p) => ( -
  • -
    -
    - {p.name} - v{p.version} - + {feedback} +
    + )} + + + + {plugins.length === 0 ? ( +
    + No plugins installed yet. +
    + Try gh:owner/repo in the box above (or via CLI). +
    +
    + ) : ( +
      + {plugins.map((p, i) => { + const trust = TRUST_BADGE[p.trustedBy]; + return ( +
    • +
      - {p.trustedBy} - -
      -
      - { - /* wire to window.deepcode.plugins.setEnabled */ + + {p.name} + + + v{p.version} + + {trust.label} + + { + /* TODO setEnabled via core */ + }} + /> + +
      + {p.contributedHookEvents.length > 0 && ( +
      + Hooks:{' '} + + {p.contributedHookEvents.join(', ')} + +
      + )} +
      -
      -
    - {p.contributedHookEvents.length > 0 && ( -
    - Hooks: {p.contributedHookEvents.join(', ')} -
    - )} -
    - hash: {p.sourceHash.slice(0, 12)} -
    - {p.warning &&
    ⚠ {p.warning}
    } -
  • - ))} -
- )} + > + hash {p.sourceHash.slice(0, 12)} +
+ {p.warning && ( +
+ ⚠ {p.warning} +
+ )} + + ); + })} + + )} +
-
+ + ); +} + +interface ToggleProps { + checked: boolean; + onChange: () => void; +} + +function Toggle({ checked, onChange }: ToggleProps): JSX.Element { + return ( + ); } diff --git a/apps/desktop/src/screens/Sessions.tsx b/apps/desktop/src/screens/Sessions.tsx index d2f5a3d..f12a1da 100644 --- a/apps/desktop/src/screens/Sessions.tsx +++ b/apps/desktop/src/screens/Sessions.tsx @@ -1,17 +1,9 @@ -// Sessions list screen — resume / inspect past conversations. -// Spec: docs/VISUAL_DESIGN.html screen #5 -// Milestone: M6-rest +// Sessions list — design-aligned. Browse + filter + resume past +// conversations. Per spec screen #5. import { useEffect, useState } from 'react'; - -interface SessionMeta { - id: string; - title?: string; - cwd: string; - createdAt: string; - updatedAt: string; - model?: string; -} +import { Card, Screen } from '../components/Screen.js'; +import { listSessions, type SessionMeta } from '../lib/tauri-api.js'; interface SessionsProps { onPick: (sessionId: string) => void; @@ -23,71 +15,132 @@ export function SessionsScreen({ onPick, onNew }: SessionsProps): JSX.Element { const [filter, setFilter] = useState(''); useEffect(() => { - // IPC call; fall back to empty list when main hasn't implemented yet. - if (window.deepcode?.sessions?.list) { - void window.deepcode.sessions - .list() - .then((rows) => setSessions(rows as SessionMeta[])) - .catch(() => setSessions([])); - } else { - setSessions([]); - } + void listSessions() + .then(setSessions) + .catch(() => setSessions([])); }, []); if (sessions === null) { - return
Loading sessions…
; + return ( + +
Loading…
+
+ ); } - const visible = sessions.filter( - (s) => - !filter || - (s.title ?? '').toLowerCase().includes(filter.toLowerCase()) || - s.cwd.toLowerCase().includes(filter.toLowerCase()), + const filtered = sessions.filter((s) => + !filter || s.id.toLowerCase().includes(filter.toLowerCase()), ); return ( -
-
+ + + New + + } + > +
setFilter(e.target.value)} - className="flex-1 rounded border border-border bg-bg px-3 py-2 text-fg outline-none focus:border-accent" + style={{ marginBottom: 14, fontFamily: 'inherit' }} /> - -
-
- {visible.length === 0 ? ( -
-

No previous sessions yet.

-

Start one with the New session button above.

-
- ) : ( -
    - {visible.map((s) => ( -
  • onPick(s.id)} - className="cursor-pointer rounded border border-border p-3 hover:border-accent" - > -
    - {s.title ?? s.id.slice(0, 8)} - - {new Date(s.updatedAt).toLocaleString()} - -
    -
    {s.cwd}
    -
  • - ))} -
- )} + Sessions are stored as JSONL under ~/.deepcode/sessions/. Resume to + continue any previous conversation. +

-
+ ); } + +function relativeTime(secs: number): string { + const diff = Math.floor(Date.now() / 1000) - secs; + if (diff < 60) return `${diff}s ago`; + if (diff < 3600) return `${Math.floor(diff / 60)}m ago`; + if (diff < 86400) return `${Math.floor(diff / 3600)}h ago`; + return `${Math.floor(diff / 86400)}d ago`; +} diff --git a/apps/desktop/src/screens/Settings.tsx b/apps/desktop/src/screens/Settings.tsx index 9c0725e..4466f12 100644 --- a/apps/desktop/src/screens/Settings.tsx +++ b/apps/desktop/src/screens/Settings.tsx @@ -1,82 +1,358 @@ -// Settings screen — inspect + edit ~/.deepcode/settings.json -// Spec: docs/VISUAL_DESIGN.html screen #6 -// Milestone: M6-rest +// Settings screen — design-aligned. Per spec screen #14. +// Top: JSON view (Monaco-lite). Bottom: flat key/value table. Both +// reflect the live settings file. Save button writes back. -import { useEffect, useState } from 'react'; +import { useEffect, useMemo, useRef, useState } from 'react'; +import { Card, Row, Screen, SectionTitle } from '../components/Screen.js'; +import { loadProjectPath } from '../lib/project.js'; +import { + getSettingsPath, + loadSettingsFile, + saveSettingsFile, +} from '../lib/tauri-api.js'; export function SettingsScreen(): JSX.Element { const [settings, setSettings] = useState | null>(null); + const [settingsPath, setSettingsPath] = useState(null); + const [projectPath, setProjectPath] = useState(); + const [rawJson, setRawJson] = useState(''); + const [parseError, setParseError] = useState(null); const [search, setSearch] = useState(''); + const [view, setView] = useState<'gui' | 'json'>('gui'); + const [saving, setSaving] = useState(false); + const [feedback, setFeedback] = useState(null); + const textareaRef = useRef(null); useEffect(() => { - void window.deepcode.settings.load().then((s) => setSettings(s)); + void (async () => { + const [s, path, project] = await Promise.all([ + loadSettingsFile(), + getSettingsPath(), + loadProjectPath(), + ]); + setSettings(s); + setSettingsPath(path); + setProjectPath(project); + setRawJson(JSON.stringify(s, null, 2)); + })(); }, []); - if (settings === null) { - return
Loading settings…
; + function handleJsonChange(text: string): void { + setRawJson(text); + try { + const parsed = JSON.parse(text); + if (typeof parsed !== 'object' || parsed === null) { + setParseError('Top-level must be an object.'); + return; + } + setSettings(parsed as Record); + setParseError(null); + setFeedback(null); + } catch (err) { + setParseError((err as Error).message); + } } - // Flat-key view: convert nested settings to dot.notation entries for display - const flat: Array<{ key: string; value: string }> = []; - function walk(prefix: string, obj: unknown): void { - if (obj && typeof obj === 'object' && !Array.isArray(obj)) { - for (const [k, v] of Object.entries(obj as Record)) { - walk(prefix ? `${prefix}.${k}` : k, v); - } - } else { - flat.push({ key: prefix, value: JSON.stringify(obj) }); + async function handleSave(): Promise { + if (!settings || parseError) return; + setSaving(true); + setFeedback(null); + try { + await saveSettingsFile(settings); + setFeedback('✓ Saved to ' + (settingsPath ?? '~/.deepcode/settings.json')); + } catch (err) { + setFeedback(`✕ Save failed: ${(err as Error).message}`); + } finally { + setSaving(false); } } - walk('', settings); - const visible = flat.filter( + // Flat key/value view + const flat = useMemo(() => { + const out: Array<{ key: string; value: string }> = []; + function walk(prefix: string, obj: unknown): void { + if (obj && typeof obj === 'object' && !Array.isArray(obj)) { + for (const [k, v] of Object.entries(obj as Record)) { + walk(prefix ? `${prefix}.${k}` : k, v); + } + } else { + out.push({ key: prefix, value: JSON.stringify(obj) }); + } + } + if (settings) walk('', settings); + return out; + }, [settings]); + + const visibleFlat = flat.filter( (e) => !search || e.key.toLowerCase().includes(search.toLowerCase()) || e.value.toLowerCase().includes(search.toLowerCase()), ); + if (settings === null) { + return ( + +
Loading…
+
+ ); + } + return ( -
-
- setSearch(e.target.value)} - className="w-full rounded border border-border bg-bg px-3 py-2 text-fg outline-none focus:border-accent" - /> -
-
- {visible.length === 0 ? ( -
-

No matching settings.

-

- Default config: ~/.deepcode/settings.json · project: .deepcode/settings.json -

+ +
+ {(['gui', 'json'] as const).map((v) => ( + + ))} +
+ + + } + > +
+ {feedback && ( +
+ {feedback}
- ) : ( - - - - - - - - - {visible.map((e) => ( - - - - - ))} - -
KeyValue
{e.key}{e.value}
+ )} + + {view === 'gui' && ( + <> + + + {projectPath ? ( + {projectPath} + ) : ( + not picked + )} + + + + {settingsPath ?? '~/.deepcode/settings.json'} + + + + + {String(settings.model ?? 'deepseek-chat')} + + + + + {String(settings.effortLevel ?? 'medium')} + + + + + {String(settings.baseURL ?? 'https://api.deepseek.com/v1')} + + + + + setSearch(e.target.value)} + style={{ + width: 200, + fontSize: 12, + padding: '4px 8px', + fontFamily: 'inherit', + }} + /> + } + flush + padding={0} + > + {visibleFlat.length === 0 ? ( +
+ {flat.length === 0 + ? 'Settings file is empty.' + : 'No matching keys.'} +
+ ) : ( + + + + + + + + + {visibleFlat.map((e) => ( + + + + + ))} + +
+ Key + + Value +
+ {e.key} + + {e.value} +
+ )} +
+ + Tip +
+ Use the JSON view (toggle in the header) to edit nested keys + and arrays directly. Save validates JSON before writing. +
+ + )} + + {view === 'json' && ( + +