@wenathlan/devthink 1.1.10 → 1.1.12

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -1,4 +1,30 @@
1
1
  # DevThink release notes
2
+ ## 1.1.12 — Page domains and tabbed interaction
3
+
4
+ ### Added
5
+
6
+ | Area | Addition |
7
+ | --- | --- |
8
+ | Page-domain topology | The web workbench now keeps each route anchor and its local visual logic in its named domain, with `home`, `providers`, `projects`, `routes`, `usage`, `notfound`, `app` and `primitives` replacing generic `components` and `pages` directories. |
9
+ | Canonical local database | DevThink now uses `~/.config/devthink/devthink.db` as the canonical SQLite path and safely seeds it from the legacy `devthink.sqlite` file when needed. |
10
+ | Shared workbench preferences | Theme, rail mode and interface zoom are stored in the CLI-owned database, exposed only through authenticated local gateway endpoints, and configurable with `devthink config set`. |
11
+ | Interactive Settings parity | The web Settings panel and Ink `/settings` view can both inspect and change shared presentation preferences without receiving provider credentials. |
12
+ | Tab-first workspace frame | The workbench promotes the browser-like tab row as its primary navigation and permits an `always`, `auto` or `off` local navigation rail. |
13
+ | Consolidated identity | The entry screen centers the product wordmark over the canonical ANSI mark while the workspace keeps the rail as its single optional identity surface. |
14
+ | Mutation boundary | JSON-bearing gateway mutations now require an explicit JSON content type; pairing, bearer authorization and local-only provider credential boundaries remain intact. |
15
+
16
+ ## 1.1.11 — Web-CLI parity and visual renewal
17
+
18
+ ### Added
19
+
20
+ | Area | Addition |
21
+ | --- | --- |
22
+ | Shared workspace contract | A renderer-neutral TypeScript destination model aligns chat, history, projects, providers, usage, routes and settings across the React web workbench and React Ink CLI. |
23
+ | Real session navigation | The web chrome now renders actual local session tabs, creates and closes tabs, and exposes a local history view without removing direct routes. |
24
+ | CLI control views | The interactive CLI now provides keyboard and slash-command access to local history, projects, providers, usage, routes and settings. |
25
+ | Browser rail | A responsive dark local rail keeps preserved control pages discoverable from the primary workspace while retaining their bookmarkable routes. |
26
+ | Entry refinement | The canonical ANSI mark, product wordmark, primary composer and local suggestions form a quieter first-intention surface. |
27
+ | Design records | Version-scoped inventory, reference synthesis and parity contract documents make the product surface and local-only security boundary explicit. |
2
28
 
3
29
  ## 1.1.10 — Entry and browser-workspace redesign
4
30
 
package/build.ts CHANGED
@@ -6,7 +6,7 @@ type BunRuntime = {
6
6
  };
7
7
 
8
8
  function bunRuntime(): BunRuntime {
9
- const runtime = (globalThis as { Bun?: BunRuntime }).Bun;
9
+ const runtime = (globalThis as unknown as { Bun?: BunRuntime }).Bun;
10
10
  if (!runtime) throw new Error("Bun is required to compile DevThink binaries.");
11
11
  return runtime;
12
12
  }
package/config.ts CHANGED
@@ -1,4 +1,4 @@
1
- import { existsSync, mkdirSync, readFileSync, renameSync, writeFileSync } from "node:fs";
1
+ import { copyFileSync, existsSync, mkdirSync, readFileSync, renameSync, writeFileSync } from "node:fs";
2
2
  import { homedir, platform } from "node:os";
3
3
  import { join } from "node:path";
4
4
 
@@ -43,6 +43,7 @@ export type DevThinkPaths = {
43
43
  sessions: string;
44
44
  workspaces: string;
45
45
  database: string;
46
+ legacyDatabase: string;
46
47
  memory: string;
47
48
  logs: string;
48
49
  };
@@ -56,11 +57,14 @@ function resolveHome(): string {
56
57
  }
57
58
 
58
59
  export function resolvePaths(root = resolveHome()): DevThinkPaths {
59
- return { home: root, config: join(root, "devthink.json"), auth: join(root, "auth.json"), identity: join(root, "identity.json"), pairings: join(root, "pairings.json"), legacyConfig: join(root, "config.json"), sessions: join(root, "sessions"), workspaces: join(root, "workspaces"), database: join(root, "devthink.sqlite"), memory: join(root, "memory"), logs: join(root, "logs") };
60
+ return { home: root, config: join(root, "devthink.json"), auth: join(root, "auth.json"), identity: join(root, "identity.json"), pairings: join(root, "pairings.json"), legacyConfig: join(root, "config.json"), sessions: join(root, "sessions"), workspaces: join(root, "workspaces"), database: join(root, "devthink.db"), legacyDatabase: join(root, "devthink.sqlite"), memory: join(root, "memory"), logs: join(root, "logs") };
60
61
  }
61
62
 
62
63
  export function ensurePaths(paths = resolvePaths()): DevThinkPaths {
63
64
  for (const path of [paths.home, paths.sessions, paths.workspaces, paths.memory, paths.logs]) mkdirSync(path, { recursive: true });
65
+ if (!existsSync(paths.database) && existsSync(paths.legacyDatabase)) {
66
+ try { copyFileSync(paths.legacyDatabase, paths.database); } catch { /* The JSON session records remain the safe compatibility fallback. */ }
67
+ }
64
68
  return paths;
65
69
  }
66
70
 
@@ -197,7 +201,9 @@ export function resolveCredential(provider: string, config: DevThinkConfig, path
197
201
  }
198
202
  const property = `${provider}ApiKey` as keyof DevThinkConfig;
199
203
  const nested = config.providers?.[provider];
200
- return nested?.auth?.value || nested?.apiKey || config[property] || (provider === (config.activeProvider || config.provider) ? config.apiKey : undefined) || process.env.DEVTHINK_API_KEY?.trim();
204
+ const legacy = config[property];
205
+ const legacyValue = typeof legacy === "string" ? legacy : undefined;
206
+ return nested?.auth?.value || nested?.apiKey || legacyValue || (provider === (config.activeProvider || config.provider) ? config.apiKey : undefined) || process.env.DEVTHINK_API_KEY?.trim();
201
207
  }
202
208
 
203
209
  export function redactValue(value: unknown): unknown {
package/devthink.ts CHANGED
@@ -9,6 +9,7 @@ import { listModes, modePrompt, resolveMode } from "./modes.ts";
9
9
  import { listModels, listProviders, streamChat } from "./providers.ts";
10
10
  import { appendMessage, createSession, exportSession, listSessions, loadSession, type Session } from "./session.ts";
11
11
  import { createPairing, createPairingLink, getIdentity, pairingStatus, revokeBrowserSessions } from "./identity.ts";
12
+ import { readPreferences, savePreference } from "./storage.ts";
12
13
  import type { ChatEvent } from "./stream.ts";
13
14
  import { banner, box, colors, formatConfig, formatEvent, statusBar } from "./ui.ts";
14
15
  import { startServer } from "./server.ts";
@@ -67,6 +68,8 @@ function printHelp(): void {
67
68
  " models --provider <id> List provider models",
68
69
  " modes List the 20 operational modes",
69
70
  " config [key] [value] Read or write local configuration",
71
+ " config preferences Show shared workbench preferences",
72
+ " config set <key> <value> Set theme, railMode, or interfaceZoom",
70
73
  " auth login <provider> Save a user-provided official credential",
71
74
  " auth status Show redacted configured credential sources",
72
75
  " auth clear <provider> Remove a stored provider credential",
@@ -151,7 +154,18 @@ async function runChat(prompt: string, parsed: ParsedArgs, runtime: ReturnType<t
151
154
  }
152
155
 
153
156
  async function handleConfig(parsed: ParsedArgs, runtime: ReturnType<typeof loadRuntime>): Promise<void> {
154
- const key = parsed.positional[1];
157
+ const action = parsed.positional[1];
158
+ if (action === "preferences") return console.log(JSON.stringify(Object.fromEntries(Object.entries(readPreferences(runtime.paths)).map(([key, preference]) => [key, preference.value])), null, 2));
159
+ if (action === "set") {
160
+ const key = parsed.positional[2];
161
+ const value = parsed.positional[3];
162
+ if (!key || !value) throw new Error("Usage: devthink config set <theme|railMode|interfaceZoom> <value>");
163
+ const valid = (key === "theme" && ["dark", "light"].includes(value)) || (key === "railMode" && ["always", "auto", "off"].includes(value)) || (key === "interfaceZoom" && /^(?:[8-9][0-9]|1[0-4][0-9]|150)$/.test(value));
164
+ if (!valid) throw new Error("Supported values: theme dark|light; railMode always|auto|off; interfaceZoom 80-150.");
165
+ savePreference(runtime.paths, key, value);
166
+ return console.log(`${key} updated.`);
167
+ }
168
+ const key = action;
155
169
  const raw = parsed.positional[2];
156
170
  if (!key) return console.log(formatConfig(redactConfig(runtime.config)));
157
171
  if (raw === undefined) return console.log(`${key} = ${JSON.stringify(getConfigValue(runtime.config, key))}`);
@@ -264,7 +278,7 @@ function handleUsage(runtime: ReturnType<typeof loadRuntime>): void {
264
278
  }
265
279
 
266
280
  function handleRoutes(): void {
267
- console.log(["web: /", "web: /providers", "web: /projects", "web: /routes", "web: /usage", "web: /w/:workspaceId/s/:sessionId/t/:tabId/:sectionId", "gateway: GET /health", "gateway: GET /providers", "gateway: GET /workspaces", "gateway: GET /usage", "gateway: POST /sessions", "gateway: POST /chat"].join("\n"));
281
+ console.log(["web: /", "web: /providers", "web: /projects", "web: /routes", "web: /usage", "web: /w/:workspaceId/s/:sessionId/t/:tabId/:sectionId", "gateway: GET /health", "gateway: GET /providers", "gateway: GET /workspaces", "gateway: GET /usage", "gateway: GET /preferences", "gateway: PATCH /preferences", "gateway: POST /sessions", "gateway: POST /chat"].join("\n"));
268
282
  }
269
283
 
270
284
  async function interactive(runtime: ReturnType<typeof loadRuntime>): Promise<void> {
package/memory.ts CHANGED
@@ -80,7 +80,7 @@ export function createMemoryStore(paths: DevThinkPaths) {
80
80
  return undefined;
81
81
  },
82
82
  list(layer?: MemoryLayer, sessionId?: string): MemoryEntry[] {
83
- const layers = layer ? [layer] : ["session", "project", "global"];
83
+ const layers: MemoryLayer[] = layer ? [layer] : ["session", "project", "global"];
84
84
  return layers.flatMap((item) => readEntries(memoryFile(paths, item, sessionId), item));
85
85
  },
86
86
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@wenathlan/devthink",
3
- "version": "1.1.10",
3
+ "version": "1.1.12",
4
4
  "repository": {
5
5
  "type": "git",
6
6
  "url": "https://github.com/wenathlan/devthink"
package/server.ts CHANGED
@@ -5,6 +5,7 @@ import { listModels, listProviders, streamChat, type ChatMessage } from "./provi
5
5
  import { ensurePaths, resolvePaths, saveConfig, type DevThinkConfig, type DevThinkPaths } from "./config.ts";
6
6
  import { consumePairing, getIdentity, pairingStatus, revokeBrowserSessions, verifyBrowserSession } from "./identity.ts";
7
7
  import { appendMessage, createSession, createTab, listSessions, loadSession, loadWorkspace, updateTab, type SessionSection } from "./session.ts";
8
+ import { readPreferences, savePreference } from "./storage.ts";
8
9
 
9
10
  export type ServerOptions = {
10
11
  port?: number;
@@ -47,6 +48,12 @@ async function readBody(request: IncomingMessage): Promise<Record<string, unknow
47
48
  return parsed && typeof parsed === "object" ? (parsed as Record<string, unknown>) : {};
48
49
  }
49
50
 
51
+ function acceptsJson(request: IncomingMessage): boolean {
52
+ return request.headers["content-type"]?.toLowerCase().includes("application/json") === true;
53
+ }
54
+
55
+ const browserPreferenceKeys = new Set(["theme", "railMode", "interfaceZoom"]);
56
+
50
57
  function messagesFromBody(value: unknown): ChatMessage[] {
51
58
  if (!Array.isArray(value)) throw new Error("messages must be an array.");
52
59
  return value.map((item) => {
@@ -107,9 +114,12 @@ async function route(request: IncomingMessage, response: ServerResponse, config:
107
114
  response.setHeader("access-control-allow-headers", "authorization, content-type");
108
115
  response.setHeader("vary", "Origin");
109
116
  response.writeHead(204);
110
- return response.end();
117
+ response.end();
118
+ return;
111
119
  }
112
120
  if (request.headers.origin && !origin) return writeJson(response, 403, { error: "Origin is not allowed by web.allowedOrigins." });
121
+ const mutationWithBody = ["POST", "PATCH", "PUT"].includes(request.method || "") && url.pathname !== "/pairings/revoke";
122
+ if (mutationWithBody && !acceptsJson(request)) return writeJson(response, 415, { error: "JSON content-type is required for mutation requests." }, origin);
113
123
  if (request.method === "GET" && url.pathname === "/health") return writeJson(response, 200, { status: "ok", service: "devthink" }, origin);
114
124
  if (request.method === "GET" && url.pathname === "/providers") return writeJson(response, 200, listProviders().map(({ id, protocol, env }) => ({ id, protocol, env })), origin);
115
125
  if (request.method === "GET" && url.pathname === "/models") {
@@ -144,6 +154,16 @@ async function route(request: IncomingMessage, response: ServerResponse, config:
144
154
  const messageCount = sessions.reduce((total, session) => total + session.messages.length, 0);
145
155
  return writeJson(response, 200, { workspaces: new Set(sessions.map((session) => session.workspaceId)).size, sessions: sessions.length, tabs: tabCount, messages: messageCount, providers: listProviders().length, paired: pairingStatus(paths).activeSessions > 0 }, origin);
146
156
  }
157
+ if (request.method === "GET" && url.pathname === "/preferences") {
158
+ return writeJson(response, 200, { preferences: Object.fromEntries(Object.entries(readPreferences(paths)).map(([key, preference]) => [key, preference.value])) }, origin);
159
+ }
160
+ if (request.method === "PATCH" && url.pathname === "/preferences") {
161
+ const body = await readBody(request);
162
+ const key = typeof body.key === "string" ? body.key : "";
163
+ const value = typeof body.value === "string" ? body.value : "";
164
+ if (!browserPreferenceKeys.has(key) || !value || value.length > 32) return writeJson(response, 400, { error: "Preference is not supported." }, origin);
165
+ return writeJson(response, 200, { preference: savePreference(paths, key, value) }, origin);
166
+ }
147
167
  if (request.method === "PATCH" && url.pathname === "/providers/active") {
148
168
  const body = await readBody(request);
149
169
  const provider = typeof body.provider === "string" ? body.provider : "";
package/storage.ts CHANGED
@@ -1,7 +1,7 @@
1
1
  /** DevThink local store: mirrors credential-free workbench records into a physical SQLite database. */
2
2
  import type { DevThinkPaths } from "./config.ts";
3
3
 
4
- type Statement = { run: (...values: unknown[]) => unknown };
4
+ type Statement = { run: (...values: unknown[]) => unknown; all: (...values: unknown[]) => unknown[] };
5
5
  type Database = { exec: (sql: string) => unknown; prepare: (sql: string) => Statement; close: () => void };
6
6
  type DatabaseConstructor = new (path: string) => Database;
7
7
 
@@ -12,6 +12,7 @@ export type StoredWorkspace = { id: string; title: string; createdAt: string; up
12
12
  export type StoredTab = { id: string; sessionId: string; workspaceId: string; label: string; provider?: string; sectionId: string; createdAt: string; updatedAt: string };
13
13
  export type StoredMessage = { id: string; sessionId: string; workspaceId: string; tabId: string; sectionId: string; role: string; content: string; createdAt: string };
14
14
  export type StoredSession = { id: string; workspaceId: string; title: string; mode: string; model?: string; provider?: string; activeTabId: string; createdAt: string; updatedAt: string; tabs: StoredTab[]; messages: StoredMessage[] };
15
+ export type StoredPreference = { key: string; value: string; updatedAt: string };
15
16
 
16
17
  function initialize(database: Database): void {
17
18
  database.exec(`
@@ -21,6 +22,7 @@ function initialize(database: Database): void {
21
22
  CREATE TABLE IF NOT EXISTS sessions (id TEXT PRIMARY KEY, workspace_id TEXT NOT NULL, title TEXT NOT NULL, mode TEXT NOT NULL, model TEXT, provider TEXT, active_tab_id TEXT NOT NULL, created_at TEXT NOT NULL, updated_at TEXT NOT NULL);
22
23
  CREATE TABLE IF NOT EXISTS tabs (id TEXT PRIMARY KEY, session_id TEXT NOT NULL, workspace_id TEXT NOT NULL, label TEXT NOT NULL, provider TEXT, section_id TEXT NOT NULL, created_at TEXT NOT NULL, updated_at TEXT NOT NULL);
23
24
  CREATE TABLE IF NOT EXISTS messages (id TEXT PRIMARY KEY, session_id TEXT NOT NULL, workspace_id TEXT NOT NULL, tab_id TEXT NOT NULL, section_id TEXT NOT NULL, role TEXT NOT NULL, content TEXT NOT NULL, created_at TEXT NOT NULL);
25
+ CREATE TABLE IF NOT EXISTS preferences (key TEXT PRIMARY KEY, value TEXT NOT NULL, updated_at TEXT NOT NULL);
24
26
  CREATE INDEX IF NOT EXISTS sessions_workspace_updated ON sessions(workspace_id, updated_at DESC);
25
27
  CREATE INDEX IF NOT EXISTS tabs_session_updated ON tabs(session_id, updated_at DESC);
26
28
  CREATE INDEX IF NOT EXISTS messages_session_created ON messages(session_id, created_at ASC);
@@ -49,3 +51,32 @@ export function mirrorSession(paths: DevThinkPaths, workspace: StoredWorkspace,
49
51
  database?.close();
50
52
  }
51
53
  }
54
+
55
+ /** Reads non-sensitive workbench preferences from the CLI-owned local database. */
56
+ export function readPreferences(paths: DevThinkPaths): Record<string, StoredPreference> {
57
+ let database: Database | undefined;
58
+ try {
59
+ database = new DatabaseDriver(paths.database);
60
+ initialize(database);
61
+ const rows = database.prepare("SELECT key, value, updated_at AS updatedAt FROM preferences ORDER BY key ASC").all() as StoredPreference[];
62
+ return Object.fromEntries(rows.map((row) => [row.key, row]));
63
+ } catch {
64
+ return {};
65
+ } finally {
66
+ database?.close();
67
+ }
68
+ }
69
+
70
+ /** Persists an allowlisted interface preference without mixing it with provider credentials. */
71
+ export function savePreference(paths: DevThinkPaths, key: string, value: string): StoredPreference {
72
+ const preference = { key, value, updatedAt: new Date().toISOString() };
73
+ let database: Database | undefined;
74
+ try {
75
+ database = new DatabaseDriver(paths.database);
76
+ initialize(database);
77
+ database.prepare("INSERT INTO preferences (key, value, updated_at) VALUES (?, ?, ?) ON CONFLICT(key) DO UPDATE SET value = excluded.value, updated_at = excluded.updated_at").run(preference.key, preference.value, preference.updatedAt);
78
+ } finally {
79
+ database?.close();
80
+ }
81
+ return preference;
82
+ }
package/terminal-ui.tsx CHANGED
@@ -1,15 +1,18 @@
1
- /** Design: DevThink v1.1.10 — Ink renders the same landing-to-browser-workspace progression as the React workbench. */
1
+ /** Design: DevThink v1.1.12 — Ink shares local workspace destinations, tab semantics, settings and command language with the React web workbench. */
2
2
  import { Box, Text, render, useApp, useInput, useWindowSize } from "ink";
3
- import { useState } from "react";
4
- import type { DevThinkConfig } from "./config.ts";
3
+ import { useMemo, useState } from "react";
4
+ import { listProviders } from "./providers.ts";
5
+ import { listSessions, type Session } from "./session.ts";
6
+ import { readPreferences, savePreference } from "./storage.ts";
7
+ import type { DevThinkConfig, DevThinkPaths } from "./config.ts";
5
8
  import type { ChatEvent } from "./stream.ts";
6
- import type { Session } from "./session.ts";
9
+ import { isWorkspaceDestination, workspaceDestination, workspaceDestinations, type WorkspaceDestination } from "./workspace.ts";
7
10
 
8
- const categories = ["features", "bugs", "refactor", "snippets", "tasks", "notes", "all", "settings"] as const;
9
- const categoryGlyphs: Record<(typeof categories)[number], string> = { features: "ϟ", bugs: "⊗", refactor: "◌", snippets: "◫", tasks: "☑", notes: "□", all: "◉", settings: "⚙" };
11
+ const categories = ["features", "bugs", "refactor", "snippets", "tasks", "notes", "all"] as const;
12
+ const categoryGlyphs: Record<(typeof categories)[number], string> = { features: "ϟ", bugs: "⊗", refactor: "◌", snippets: "◫", tasks: "☑", notes: "□", all: "◉" };
10
13
  const entryArt = [" +++++", " +++++++++++++++", " ++++++ ++++++", " +++++ +++++", " +++++ +++++", " +++++ +++++", " ++++++ ++++++", " +++++++++++++", " +++++"];
11
14
 
12
- type Runtime = { config: DevThinkConfig };
15
+ type Runtime = { config: DevThinkConfig; paths: DevThinkPaths };
13
16
  type Execute = (prompt: string, current: Session | undefined, onEvent: (event: ChatEvent) => void) => Promise<Session | undefined>;
14
17
  type Entry = { role: "user" | "devthink" | "error"; title: string; body: string };
15
18
 
@@ -20,50 +23,83 @@ function clip(value: string, width: number): string {
20
23
  function TerminalEntry({ draft, provider, width }: { draft: string; provider: string; width: number }) {
21
24
  return <Box flexDirection="column" width="100%" height="100%" minHeight={20} paddingX={2} paddingY={1} justifyContent="space-between">
22
25
  <Box justifyContent="space-between"><Text color="#807970">✦ DEVTHINK</Text><Text color="#8ab4f8">LOCAL-FIRST WORKBENCH</Text></Box>
23
- <Box flexDirection="column" alignItems="center">
24
- <Box flexDirection="column" alignItems="center">{entryArt.map((line, index) => <Text key={`${line}-${index}`} color="#382c23">{line}</Text>)}</Box>
25
- <Box marginTop={-1}><Text color="#ff5f00" bold>DEVTHINK</Text></Box>
26
- <Box marginTop={1}><Text color="#746f66">begin with one intention</Text></Box>
27
- <Box marginTop={2}><Text color="#282724">┌{"─".repeat(Math.max(16, width - 2))}┐</Text></Box>
28
- <Text color="#e8e1d7">│ <Text color="#ff5f00">›_ </Text><Text color={draft ? "#e8e1d7" : "#746f66"}>{clip(draft || "Ask anything…", Math.max(12, width - 22))}</Text><Text color="#8ab4f8"> {provider}</Text><Text color="#ff5f00"> [open]</Text> │</Text>
29
- <Text color="#282724">└{"─".repeat(Math.max(16, width - 2))}┘</Text>
30
- </Box>
26
+ <Box flexDirection="column" alignItems="center"><Box flexDirection="column" alignItems="center">{entryArt.map((line, index) => <Text key={`${line}-${index}`} color="#382c23">{line}</Text>)}</Box><Box marginTop={-1}><Text color="#ff5f00" bold>DEVTHINK</Text></Box><Box marginTop={1}><Text color="#746f66">begin with one intention</Text></Box><Box marginTop={2}><Text color="#282724">┌{"─".repeat(Math.max(16, width - 2))}┐</Text></Box><Text color="#e8e1d7">│ <Text color="#ff5f00">›_ </Text><Text color={draft ? "#e8e1d7" : "#746f66"}>{clip(draft || "Ask anything…", Math.max(12, width - 22))}</Text><Text color="#8ab4f8"> {provider}</Text><Text color="#ff5f00"> [open]</Text> │</Text><Text color="#282724">└{"─".repeat(Math.max(16, width - 2))}┘</Text></Box>
31
27
  <Text color="#746f66">enter opens a local workspace · esc exits</Text>
32
28
  </Box>;
33
29
  }
34
30
 
31
+ function projectRows(runtime: Runtime): string[] {
32
+ const projects = new Map<string, { title: string; sessions: number }>();
33
+ for (const session of listSessions(runtime.paths)) projects.set(session.workspaceId, { title: session.title, sessions: (projects.get(session.workspaceId)?.sessions || 0) + 1 });
34
+ return [...projects.entries()].map(([id, project]) => `${id} ${project.sessions} ${project.sessions === 1 ? "session" : "sessions"} ${project.title}`);
35
+ }
36
+
37
+ function viewRows(view: WorkspaceDestination, runtime: Runtime, session: Session | undefined): string[] {
38
+ if (view === "history") return listSessions(runtime.paths).map((item) => `${item.id} ${item.title}`) || ["No saved sessions."];
39
+ if (view === "projects") return projectRows(runtime) || ["No local projects."];
40
+ if (view === "providers") return listProviders().map((provider) => `${provider.id} ${provider.protocol} ${provider.env}`);
41
+ if (view === "usage") {
42
+ const sessions = listSessions(runtime.paths);
43
+ return [`workspaces ${new Set(sessions.map((item) => item.workspaceId)).size}`, `sessions ${sessions.length}`, `tabs ${sessions.reduce((total, item) => total + item.tabs.length, 0)}`, `messages ${sessions.reduce((total, item) => total + item.messages.length, 0)}`];
44
+ }
45
+ if (view === "routes") return ["GET /health", "GET /providers", "GET /workspaces", "GET /usage", "GET /preferences", "PATCH /preferences", "POST /sessions", "POST /chat"];
46
+ if (view === "settings") {
47
+ const preferences = Object.fromEntries(Object.entries(readPreferences(runtime.paths)).map(([key, preference]) => [key, preference.value]));
48
+ return [`theme ${preferences.theme || "dark"}`, `railMode ${preferences.railMode || "auto"}`, `interfaceZoom ${preferences.interfaceZoom || "100"}`, "edit: /settings set theme dark|light", "edit: /settings set railMode always|auto|off", "edit: /settings set interfaceZoom 80-150", "credentials remain in ~/.config/devthink/auth.json"];
49
+ }
50
+ if (session) return [`workspace ${session.workspaceId}`, `session ${session.id}`, `tab ${session.activeTabId}`];
51
+ return ["Describe the next feature, bug or piece of work below."];
52
+ }
53
+
35
54
  function TerminalWorkspace({ runtime, execute, version }: { runtime: Runtime; execute: Execute; version: string }) {
36
55
  const { exit } = useApp();
37
56
  const { columns } = useWindowSize();
38
57
  const [entered, setEntered] = useState(false);
39
58
  const [active, setActive] = useState<(typeof categories)[number]>("all");
59
+ const [view, setView] = useState<WorkspaceDestination>("chat");
40
60
  const [entries, setEntries] = useState<Entry[]>([]);
41
61
  const [session, setSession] = useState<Session>();
42
62
  const [draft, setDraft] = useState("");
43
63
  const [pending, setPending] = useState(false);
64
+ const [preferenceRevision, setPreferenceRevision] = useState(0);
44
65
  const provider = runtime.config.activeProvider || runtime.config.provider || "local";
45
66
  const model = runtime.config.activeModel || runtime.config.model || "not configured";
46
67
  const compact = columns < 100;
47
- const tabLine = compact ? categories.map((category) => categoryGlyphs[category]).join(" ") : categories.map((category) => `${category === active ? "[" : ""}${categoryGlyphs[category]} ${category}${category === active ? "]" : ""}`).join(" ");
48
68
  const terminalWidth = Math.max(38, columns - (compact ? 2 : 6));
69
+ const tabLine = compact ? workspaceDestinations.map((destination) => destination.glyph).join(" ") : workspaceDestinations.map((destination) => `${destination.id === view ? "[" : ""}${destination.glyph} ${destination.label}${destination.id === view ? "]" : ""}`).join(" ");
70
+ const rows = useMemo(() => viewRows(view, runtime, session), [preferenceRevision, runtime, session, view]);
71
+
72
+ function openView(next: WorkspaceDestination) {
73
+ setView(next);
74
+ setEntries((current) => [...current, { role: "devthink", title: workspaceDestination(next).label, body: rows.join(" · ") }]);
75
+ }
49
76
 
50
77
  async function submit() {
51
78
  const prompt = draft.trim();
52
79
  if (!prompt || pending) return;
53
80
  if (prompt === "/exit" || prompt === "/quit") return exit();
54
81
  if (!entered) {
55
- setEntries([{ role: "user", title: "first intention", body: prompt }, { role: "devthink", title: "local workspace ready", body: "The first command opened a local DevThink session. Configure a provider whenever the work needs a model." }]);
82
+ setEntries([{ role: "user", title: "first intention", body: prompt }, { role: "devthink", title: "local workspace ready", body: "The first command opened a local DevThink session. Use arrows or /view to inspect local history, projects, providers, usage, routes and settings." }]);
56
83
  setDraft("");
57
84
  setEntered(true);
58
85
  return;
59
86
  }
60
87
  if (prompt === "/clear") { setEntries([]); setDraft(""); return; }
61
- if (prompt === "/new") { setSession(undefined); setEntries([]); setDraft(""); return; }
62
- if (prompt === "/help") {
63
- setEntries((current) => [...current, { role: "devthink", title: "command reference", body: "/new /clear /models /modes /sessions /exit" }]);
88
+ if (prompt === "/new") { setSession(undefined); setEntries([]); setView("chat"); setDraft(""); return; }
89
+ const viewMatch = prompt.match(/^\/(?:view\s+)?(chat|history|projects|providers|usage|routes|settings)$/);
90
+ if (viewMatch && isWorkspaceDestination(viewMatch[1])) { openView(viewMatch[1]); setDraft(""); return; }
91
+ if (prompt === "/sessions") { openView("history"); setDraft(""); return; }
92
+ const preferenceMatch = prompt.match(/^\/(?:settings\s+)?set\s+(theme|railMode|interfaceZoom)\s+(\S+)$/);
93
+ if (preferenceMatch) {
94
+ const [, key, value] = preferenceMatch;
95
+ const valid = (key === "theme" && ["dark", "light"].includes(value)) || (key === "railMode" && ["always", "auto", "off"].includes(value)) || (key === "interfaceZoom" && /^(?:[8-9][0-9]|1[0-4][0-9]|150)$/.test(value));
96
+ if (!valid) setEntries((current) => [...current, { role: "error", title: "settings", body: "Use theme dark|light, railMode always|auto|off, or interfaceZoom 80-150." }]);
97
+ else { savePreference(runtime.paths, key, value); setPreferenceRevision((current) => current + 1); setView("settings"); setEntries((current) => [...current, { role: "devthink", title: "settings", body: `${key} updated to ${value}.` }]); }
64
98
  setDraft("");
65
99
  return;
66
100
  }
101
+ if (prompt === "/help") { setEntries((current) => [...current, { role: "devthink", title: "command reference", body: "/view chat|history|projects|providers|usage|routes|settings /settings set <key> <value> /new /clear /models /modes /sessions /exit" }]); setDraft(""); return; }
102
+ setView("chat");
67
103
  setEntries((current) => [...current, { role: "user", title: active, body: prompt }, { role: "devthink", title: "connecting", body: "waiting for local gateway events…" }]);
68
104
  setDraft("");
69
105
  setPending(true);
@@ -84,25 +120,27 @@ function TerminalWorkspace({ runtime, execute, version }: { runtime: Runtime; ex
84
120
  if (key.escape || (key.ctrl && input === "c")) return exit();
85
121
  if (key.return) return void submit();
86
122
  if (key.backspace || key.delete) return setDraft((current) => current.slice(0, -1));
87
- if (entered && (key.leftArrow || key.tab)) return setActive((current) => categories[(categories.indexOf(current) + categories.length - 1) % categories.length]);
88
- if (entered && key.rightArrow) return setActive((current) => categories[(categories.indexOf(current) + 1) % categories.length]);
123
+ if (entered && key.upArrow) return setView((current) => workspaceDestinations[(workspaceDestinations.findIndex((item) => item.id === current) + workspaceDestinations.length - 1) % workspaceDestinations.length].id);
124
+ if (entered && key.downArrow) return setView((current) => workspaceDestinations[(workspaceDestinations.findIndex((item) => item.id === current) + 1) % workspaceDestinations.length].id);
125
+ if (entered && view === "chat" && (key.leftArrow || key.tab)) return setActive((current) => categories[(categories.indexOf(current) + categories.length - 1) % categories.length]);
126
+ if (entered && view === "chat" && key.rightArrow) return setActive((current) => categories[(categories.indexOf(current) + 1) % categories.length]);
89
127
  if (!key.ctrl && !key.meta && input) setDraft((current) => current + input);
90
128
  });
91
129
 
92
130
  if (!entered) return <TerminalEntry draft={draft} provider={provider} width={terminalWidth} />;
93
131
 
94
132
  return <Box flexDirection="column" width="100%" height="100%" minHeight={20} paddingX={compact ? 1 : 3} paddingY={1}>
95
- <Box justifyContent="space-between"><Text color="#a19a91">✦ DEVTHINK <Text color="#746f66">/ local session</Text></Text><Text color="#8ab4f8">◉ {provider.toUpperCase()}</Text></Box>
133
+ <Box justifyContent="space-between"><Text color="#a19a91">✦ DEVTHINK <Text color="#746f66">/ {workspaceDestination(view).label}</Text></Text><Text color="#8ab4f8">◉ {provider.toUpperCase()}</Text></Box>
96
134
  <Text color="#282724">{"─".repeat(terminalWidth)}</Text>
97
135
  <Text color="#746f66">{clip(tabLine, terminalWidth)}</Text>
98
- <Box marginTop={1}><Text color="#e8e1d7">{categoryGlyphs[active]} {active.toUpperCase()} <Text color="#746f66">· {entries.length} entries · {clip(model, 22)}</Text></Text></Box>
99
- <Box flexDirection="column" flexGrow={1} justifyContent={entries.length ? "flex-start" : "center"} alignItems={entries.length ? "flex-start" : "center"}>
100
- {entries.length ? entries.map((entry, index) => <Box key={`${entry.title}-${index}`} flexDirection="column" marginBottom={1} paddingLeft={1}><Box><Text color={entry.role === "user" ? "#ff5f00" : entry.role === "error" ? "#ff6b6b" : "#8ab4f8"}>▎ {entry.title.toUpperCase()}</Text></Box><Box><Text color="#e8e1d7">{clip(entry.body, terminalWidth - 6)}</Text></Box></Box>) : <Box flexDirection="column" alignItems="center"><Box><Text color="#4b4842">Nothing is active yet.</Text></Box><Box><Text color="#625c54">Describe the next feature, bug or piece of work below.</Text></Box><Box marginTop={2}><Text color="#ff5f00" bold>DEVTHINK</Text></Box></Box>}
136
+ <Box marginTop={1}><Text color="#e8e1d7">{workspaceDestination(view).glyph} {workspaceDestination(view).label.toUpperCase()} <Text color="#746f66">· {clip(model, 22)}</Text></Text></Box>
137
+ <Box flexDirection="column" flexGrow={1} justifyContent={entries.length && view === "chat" ? "flex-start" : "center"} alignItems={entries.length && view === "chat" ? "flex-start" : "center"}>
138
+ {view !== "chat" ? rows.map((row, index) => <Text key={`${view}-${index}`} color={index === 0 ? "#e8e1d7" : "#746f66"}>▎ {clip(row, terminalWidth - 4)}</Text>) : entries.length ? entries.map((entry, index) => <Box key={`${entry.title}-${index}`} flexDirection="column" marginBottom={1} paddingLeft={1}><Box><Text color={entry.role === "user" ? "#ff5f00" : entry.role === "error" ? "#ff6b6b" : "#8ab4f8"}>▎ {entry.title.toUpperCase()}</Text></Box><Box><Text color="#e8e1d7">{clip(entry.body, terminalWidth - 6)}</Text></Box></Box>) : <Box flexDirection="column" alignItems="center"><Text color="#4b4842">Nothing is active yet.</Text><Text color="#625c54">Describe the next feature, bug or piece of work below.</Text><Box marginTop={2}><Text color="#ff5f00" bold>DEVTHINK</Text></Box></Box>}
101
139
  </Box>
102
140
  <Text color="#282724">┌{"─".repeat(Math.max(16, terminalWidth - 2))}┐</Text>
103
- <Text color="#e8e1d7">│ <Text color="#ff5f00">›_ </Text><Text color={draft ? "#e8e1d7" : "#746f66"}>{clip(draft || `Ask DevThink about ${active === "all" ? "the work…" : `${active}…`}`, Math.max(12, terminalWidth - 20))}</Text><Text color="#8ab4f8"> {provider}</Text><Text color="#ff5f00"> [run]</Text> │</Text>
141
+ <Text color="#e8e1d7">│ <Text color="#ff5f00">›_ </Text><Text color={draft ? "#e8e1d7" : "#746f66"}>{clip(draft || (view === "chat" ? `Ask DevThink about ${active === "all" ? "the work…" : `${active}…`}` : `Type /view chat to return…`), Math.max(12, terminalWidth - 20))}</Text><Text color="#8ab4f8"> {provider}</Text><Text color="#ff5f00"> [run]</Text> │</Text>
104
142
  <Text color="#282724">└{"─".repeat(Math.max(16, terminalWidth - 2))}┘</Text>
105
- <Text color="#746f66">provider credentials stay in the local cli · v{version} · esc exit</Text>
143
+ <Text color="#746f66">↑↓ destinations · ←→ categories · /help commands · v{version} · esc exit</Text>
106
144
  </Box>;
107
145
  }
108
146
 
package/workspace.ts ADDED
@@ -0,0 +1,20 @@
1
+ /** DevThink workspace contract: renderer-neutral destination names, glyphs and labels shared by web and Ink. */
2
+ export const workspaceDestinations = [
3
+ { id: "chat", label: "chat", glyph: "◉" },
4
+ { id: "history", label: "history", glyph: "◷" },
5
+ { id: "projects", label: "projects", glyph: "▦" },
6
+ { id: "providers", label: "providers", glyph: "◌" },
7
+ { id: "usage", label: "usage", glyph: "◫" },
8
+ { id: "routes", label: "routes", glyph: "⌘" },
9
+ { id: "settings", label: "settings", glyph: "⚙" },
10
+ ] as const;
11
+
12
+ export type WorkspaceDestination = (typeof workspaceDestinations)[number]["id"];
13
+
14
+ export function isWorkspaceDestination(value: string): value is WorkspaceDestination {
15
+ return workspaceDestinations.some((destination) => destination.id === value);
16
+ }
17
+
18
+ export function workspaceDestination(value: WorkspaceDestination) {
19
+ return workspaceDestinations.find((destination) => destination.id === value) || workspaceDestinations[0];
20
+ }