@wenathlan/devthink 1.1.8 → 1.1.9

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,5 +1,17 @@
1
1
  # Changelog
2
2
 
3
+ ## 1.1.9 — Unified terminal workspace
4
+
5
+ This release reconstructs DevThink as one command-first terminal workspace across its static React workbench and interactive CLI. The browser is the route-addressable React surface; the CLI is a React Ink terminal surface with the same categories, empty states, local credential boundary and command rail.
6
+
7
+ | Area | Change |
8
+ | --- | --- |
9
+ | Shared information architecture | Aligns `features`, `bugs`, `refactor`, `snippets`, `tasks`, `notes`, `all` and `settings` across web and CLI. |
10
+ | Browser workbench | Replaces the dashboard shell with a sparse black terminal workspace, top category strip, central canvas and bottom command rail. |
11
+ | Interactive CLI | Adds an Ink React renderer for TTY sessions, preserving the canonical ANSI fallback for noninteractive and compatibility contexts. |
12
+ | Binary portability | Uses Ink rather than a native TUI dependency so Bun can bundle the CLI for Linux, macOS and Windows release targets. |
13
+ | Release metadata | Aligns the CLI, static workbench, mobile shell, extension and package adapters on version 1.1.9. |
14
+
3
15
  ## 1.1.8 — Workflow trust boundaries and package adapters
4
16
 
5
17
  This release removes `workflow_run` as a trigger from privileged distribution workflows. Publishing jobs now check out only a release tag or a manually supplied authenticated release tag, preventing a completed workflow from selecting arbitrary source code before package or release credentials become available.
package/devthink.ts CHANGED
@@ -116,7 +116,7 @@ function printEvents(events: ChatEvent[], json: boolean): void {
116
116
  process.stdout.write("\n");
117
117
  }
118
118
 
119
- async function runChat(prompt: string, parsed: ParsedArgs, runtime: ReturnType<typeof loadRuntime>, current?: Session): Promise<Session | undefined> {
119
+ async function runChat(prompt: string, parsed: ParsedArgs, runtime: ReturnType<typeof loadRuntime>, current?: Session, render?: { onEvent?: (event: ChatEvent) => void; silent?: boolean }): Promise<Session | undefined> {
120
120
  const provider = stringFlag(parsed, "provider") || runtime.config.activeProvider || runtime.config.provider;
121
121
  const model = stringFlag(parsed, "model") || runtime.config.activeModel || runtime.config.model;
122
122
  const mode = stringFlag(parsed, "mode") || "chat";
@@ -137,7 +137,8 @@ async function runChat(prompt: string, parsed: ParsedArgs, runtime: ReturnType<t
137
137
  events.push(event);
138
138
  if (event.type === "text") text += event.text;
139
139
  if (event.type === "reasoning") reasoning += event.text;
140
- if (!json) {
140
+ render?.onEvent?.(event);
141
+ if (!json && !render?.silent) {
141
142
  const rendered = formatEvent(event);
142
143
  if (rendered) process.stdout.write(rendered);
143
144
  }
@@ -145,7 +146,7 @@ async function runChat(prompt: string, parsed: ParsedArgs, runtime: ReturnType<t
145
146
  const withAssistant = appendMessage(runtime.paths, nextSession, { role: "assistant", content: text });
146
147
  runtime.memory.remember({ layer: "session", key: withAssistant.id, content: text.slice(0, 2000) });
147
148
  if (json) console.log(JSON.stringify({ workspaceId: withAssistant.workspaceId, sessionId: withAssistant.id, tabId: withAssistant.activeTabId, messageId: withAssistant.messages.at(-1)?.id, provider, model, mode: modeDefinition.id, text, reasoning }));
148
- else process.stdout.write("\n");
149
+ else if (!render?.silent) process.stdout.write("\n");
149
150
  return withAssistant;
150
151
  }
151
152
 
@@ -267,6 +268,14 @@ function handleRoutes(): void {
267
268
  }
268
269
 
269
270
  async function interactive(runtime: ReturnType<typeof loadRuntime>): Promise<void> {
271
+ if (process.stdin.isTTY && process.stdout.isTTY && process.env.DEVTHINK_PLAIN !== "1") {
272
+ try {
273
+ const { startTerminalWorkspace } = await import("./terminal-ui.tsx");
274
+ return await startTerminalWorkspace(runtime, version(), (prompt, current, onEvent) => runChat(prompt, { flags: { mode: runtime.config.mode || "chat" }, positional: [] }, runtime, current, { onEvent, silent: true }));
275
+ } catch (error) {
276
+ process.stderr.write(`${colors.yellow}Terminal workspace fallback: ${error instanceof Error ? error.message : "renderer unavailable"}${colors.reset}\n`);
277
+ }
278
+ }
270
279
  const input = createInterface({ input: stdin, output: stdout, terminal: true });
271
280
  let session: Session | undefined;
272
281
  let mode = runtime.config.mode || "chat";
package/package.json CHANGED
@@ -1,11 +1,17 @@
1
1
  {
2
2
  "name": "@wenathlan/devthink",
3
- "version": "1.1.8",
4
- "description": "DevThink is an AI development workbench with provider-owned endpoints, normalized streaming, sessions, memory, an embedded gateway and a terminal/web interface.",
5
- "license": "MIT",
6
- "type": "module",
7
- "bin": {
8
- "devthink": "./devthink.ts"
3
+ "version": "1.1.9",
4
+ "repository": {
5
+ "type": "git",
6
+ "url": "https://github.com/wenathlan/devthink"
7
+ },
8
+ "dependencies": {
9
+ "ink": "^7.1.1",
10
+ "react": "^19.2.8"
11
+ },
12
+ "devDependencies": {
13
+ "@types/react": "^19.2.18",
14
+ "bun-types": "^1.4.0"
9
15
  },
10
16
  "exports": {
11
17
  ".": "./devthink.ts",
@@ -20,27 +26,26 @@
20
26
  "./stream": "./stream.ts",
21
27
  "./sync": "./sync.ts"
22
28
  },
29
+ "bin": {
30
+ "devthink": "./devthink.ts"
31
+ },
32
+ "bugs": {
33
+ "url": "https://github.com/wenathlan/devthink/issues"
34
+ },
35
+ "description": "DevThink is an AI development workbench with provider-owned endpoints, normalized streaming, sessions, memory, an embedded gateway and a terminal/web interface.",
36
+ "engines": {
37
+ "bun": ">=1.1.1",
38
+ "node": ">=26.7.0"
39
+ },
23
40
  "files": [
24
41
  "*.ts",
42
+ "*.tsx",
25
43
  "README.md",
26
44
  "LICENSE",
27
45
  "CHANGELOG.md",
28
46
  "release-notes"
29
47
  ],
30
- "scripts": {
31
- "start": "bun run devthink.ts",
32
- "dev": "bun --watch run devthink.ts",
33
- "test": "node --experimental-strip-types --test tests/*.test.ts",
34
- "check": "bun run check:secrets && bun run test",
35
- "check:secrets": "bun run check-secrets.ts",
36
- "build:binary": "bun run build.ts",
37
- "build": "bun build ./devthink.ts --outdir dist --target bun",
38
- "release": "bun run check && bun run build:binary"
39
- },
40
- "engines": {
41
- "bun": ">=1.1.1",
42
- "node": ">=26.7.0"
43
- },
48
+ "homepage": "https://wenathlan.github.io/devthink/",
44
49
  "keywords": [
45
50
  "ai",
46
51
  "cli",
@@ -52,16 +57,20 @@
52
57
  "embedded-gateway",
53
58
  "web-workbench"
54
59
  ],
55
- "repository": {
56
- "type": "git",
57
- "url": "https://github.com/wenathlan/devthink"
58
- },
59
- "homepage": "https://wenathlan.github.io/devthink/",
60
- "bugs": {
61
- "url": "https://github.com/wenathlan/devthink/issues"
62
- },
60
+ "license": "MIT",
63
61
  "publishConfig": {
64
62
  "access": "public",
65
63
  "registry": "https://registry.npmjs.org"
66
- }
64
+ },
65
+ "scripts": {
66
+ "start": "bun run devthink.ts",
67
+ "dev": "bun --watch run devthink.ts",
68
+ "test": "node --experimental-strip-types --test tests/*.test.ts",
69
+ "check": "bun run check:secrets && bun run test",
70
+ "check:secrets": "bun run check-secrets.ts",
71
+ "build:binary": "bun run build.ts",
72
+ "build": "bun build ./devthink.ts --outdir dist --target bun",
73
+ "release": "bun run check && bun run build:binary"
74
+ },
75
+ "type": "module"
67
76
  }
@@ -0,0 +1,86 @@
1
+ /** Style: DevThink Unified Terminal Workspace — Ink renders the same sparse categories, canvas and command rail as web/TerminalWorkspace. */
2
+ import { Box, Text, render, useApp, useInput, useWindowSize } from "ink";
3
+ import { useState } from "react";
4
+ import type { DevThinkConfig } from "./config.ts";
5
+ import type { ChatEvent } from "./stream.ts";
6
+ import type { Session } from "./session.ts";
7
+
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: "⚙" };
10
+
11
+ type Runtime = { config: DevThinkConfig };
12
+ type Execute = (prompt: string, current: Session | undefined, onEvent: (event: ChatEvent) => void) => Promise<Session | undefined>;
13
+ type Entry = { role: "user" | "devthink" | "error"; title: string; body: string };
14
+
15
+ function clip(value: string, width: number): string {
16
+ return value.length > width ? `${value.slice(0, Math.max(0, width - 1))}…` : value;
17
+ }
18
+
19
+ function TerminalWorkspace({ runtime, execute, version }: { runtime: Runtime; execute: Execute; version: string }) {
20
+ const { exit } = useApp();
21
+ const { columns } = useWindowSize();
22
+ const [active, setActive] = useState<(typeof categories)[number]>("all");
23
+ const [entries, setEntries] = useState<Entry[]>([]);
24
+ const [session, setSession] = useState<Session>();
25
+ const [draft, setDraft] = useState("");
26
+ const [pending, setPending] = useState(false);
27
+ const provider = runtime.config.activeProvider || runtime.config.provider || "local";
28
+ const model = runtime.config.activeModel || runtime.config.model || "not configured";
29
+ const compact = columns < 100;
30
+ const tabLine = compact ? categories.map((category) => categoryGlyphs[category]).join(" ") : categories.map((category) => `${category === active ? "[" : ""}${categoryGlyphs[category]} ${category}${category === active ? "]" : ""}`).join(" ");
31
+ const terminalWidth = Math.max(38, columns - (compact ? 2 : 6));
32
+
33
+ async function submit() {
34
+ const prompt = draft.trim();
35
+ if (!prompt || pending) return;
36
+ if (prompt === "/exit" || prompt === "/quit") return exit();
37
+ if (prompt === "/clear") { setEntries([]); setDraft(""); return; }
38
+ if (prompt === "/new") { setSession(undefined); setEntries([]); setDraft(""); return; }
39
+ if (prompt === "/help") {
40
+ setEntries((current) => [...current, { role: "devthink", title: "command reference", body: "/new /clear /models /modes /sessions /exit" }]);
41
+ setDraft("");
42
+ return;
43
+ }
44
+ setEntries((current) => [...current, { role: "user", title: active, body: prompt }, { role: "devthink", title: "connecting", body: "waiting for local gateway events…" }]);
45
+ setDraft("");
46
+ setPending(true);
47
+ let partial = "";
48
+ try {
49
+ const next = await execute(prompt, session, (event) => {
50
+ if (event.type === "text" || event.type === "reasoning") partial += event.text;
51
+ setEntries((current) => current.map((entry, index) => index === current.length - 1 ? { ...entry, title: event.type === "start" ? "streaming" : "devthink", body: partial || "connecting to local provider…" } : entry));
52
+ });
53
+ setSession(next);
54
+ setEntries((current) => current.map((entry, index) => index === current.length - 1 ? { ...entry, title: "devthink", body: partial || next?.messages.at(-1)?.content || "the provider returned no text." } : entry));
55
+ } catch (error) {
56
+ setEntries((current) => current.map((entry, index) => index === current.length - 1 ? { role: "error", title: "gateway error", body: error instanceof Error ? error.message : "request could not be completed" } : entry));
57
+ } finally { setPending(false); }
58
+ }
59
+
60
+ useInput((input, key) => {
61
+ if (key.escape || (key.ctrl && input === "c")) return exit();
62
+ if (key.leftArrow || key.tab) return setActive((current) => categories[(categories.indexOf(current) + categories.length - 1) % categories.length]);
63
+ if (key.rightArrow) return setActive((current) => categories[(categories.indexOf(current) + 1) % categories.length]);
64
+ if (key.return) return void submit();
65
+ if (key.backspace || key.delete) return setDraft((current) => current.slice(0, -1));
66
+ if (!key.ctrl && !key.meta && input) setDraft((current) => current + input);
67
+ });
68
+
69
+ return <Box flexDirection="column" width="100%" height="100%" minHeight={20} paddingX={compact ? 1 : 3} paddingY={1}>
70
+ <Text color="#a19a91">{clip(`▣ DEVTHINK ${tabLine}`, terminalWidth - 2)} <Text color="#8ab4f8">{provider.toUpperCase()}</Text></Text>
71
+ <Text color="#282724">{"─".repeat(terminalWidth)}</Text>
72
+ <Text color="#e8e1d7">{categoryGlyphs[active]} {active.toUpperCase()} ENTRIES <Text color="#746f66">· {entries.length} entries · {clip(model, 22)}</Text></Text>
73
+ <Box flexDirection="column" flexGrow={1} justifyContent={entries.length ? "flex-start" : "center"} alignItems={entries.length ? "flex-start" : "center"}>
74
+ {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">no {active === "all" ? "entries" : active} planned yet.</Text></Box><Box><Text color="#625c54">think {active === "all" ? "feature add a local provider" : `${active} …`}</Text></Box><Box marginTop={2}><Text color="#ff5f00" bold>DEVTHINK</Text></Box></Box>}
75
+ </Box>
76
+ <Text color="#282724">┌{"─".repeat(Math.max(16, terminalWidth - 2))}┐</Text>
77
+ <Text color="#e8e1d7">│ <Text color="#ff5f00">›_ </Text><Text color={draft ? "#e8e1d7" : "#746f66"}>{clip(draft || `think ${active === "all" ? "feature, bug, task, note…" : `${active} …`}`, Math.max(12, terminalWidth - 20))}</Text><Text color="#8ab4f8"> {provider}</Text><Text color="#ff5f00"> [run]</Text> │</Text>
78
+ <Text color="#282724">└{"─".repeat(Math.max(16, terminalWidth - 2))}┘</Text>
79
+ <Text color="#746f66">provider credentials stay in the local cli · v{version} · esc exit</Text>
80
+ </Box>;
81
+ }
82
+
83
+ export async function startTerminalWorkspace(runtime: Runtime, version: string, execute: Execute): Promise<void> {
84
+ const app = render(<TerminalWorkspace runtime={runtime} version={version} execute={execute} />, { exitOnCtrlC: false });
85
+ await app.waitUntilExit();
86
+ }
package/ui.ts CHANGED
@@ -1,12 +1,15 @@
1
+ /** Style: DevThink Orbital Signal Room — terminal workspace uses canonical ANSI art, orange intent and orb-blue connection states. */
1
2
  import type { ChatEvent } from "./stream.ts";
2
3
 
3
4
  export const colors = {
4
- background: "\u001b[38;5;236m",
5
- panel: "\u001b[38;5;238m",
6
- text: "\u001b[38;5;252m",
7
- muted: "\u001b[38;5;245m",
8
- cyan: "\u001b[38;5;116m",
9
- blue: "\u001b[38;5;110m",
5
+ background: "\u001b[38;5;234m",
6
+ panel: "\u001b[38;5;240m",
7
+ text: "\u001b[38;5;255m",
8
+ muted: "\u001b[38;5;246m",
9
+ cyan: "\u001b[38;5;117m",
10
+ blue: "\u001b[38;5;111m",
11
+ orange: "\u001b[38;5;208m",
12
+ orangeSoft: "\u001b[38;5;215m",
10
13
  green: "\u001b[38;5;151m",
11
14
  red: "\u001b[38;5;167m",
12
15
  yellow: "\u001b[38;5;222m",
@@ -14,7 +17,7 @@ export const colors = {
14
17
  bold: "\u001b[1m",
15
18
  };
16
19
 
17
- const frames = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧"];
20
+ const frames = ["◐", "◓", "◑", "◒"];
18
21
 
19
22
  /** Canonical DevThink ANSI mark transcribed from docs/logo.md. */
20
23
  const canonicalMark = String.raw`
@@ -77,38 +80,56 @@ function color(code: string, value: string): string {
77
80
  return `${code}${value}${colors.reset}`;
78
81
  }
79
82
 
83
+ function stripAnsi(value: string): string {
84
+ return value.replace(/\u001b\[[0-?]*[ -/]*[@-~]/g, "");
85
+ }
86
+
80
87
  function pad(value: string, width: number): string {
81
- return value.length >= width ? value.slice(0, width) : value + " ".repeat(width - value.length);
88
+ const visible = stripAnsi(value);
89
+ return visible.length >= width ? value.slice(0, Math.max(0, width - Math.max(0, visible.length - width))) : value + " ".repeat(width - visible.length);
82
90
  }
83
91
 
84
92
  export function banner(version: string): string {
85
- const mark = canonicalMark.split("\n").map((line) => color(colors.yellow, line)).join("\n");
86
- const title = color(colors.yellow + colors.bold, "DEVTHINK");
87
- return [mark, `${title} ${color(colors.muted, `v${version}`)}`, color(colors.muted, "Embedded gateway · terminal workbench · user-owned configuration")].join("\n");
93
+ const mark = canonicalMark.split("\n").map((line) => color(colors.orange, line)).join("\n");
94
+ const title = color(colors.orange + colors.bold, "DEVTHINK");
95
+ const signal = color(colors.blue, "◆ local workspace");
96
+ return [
97
+ color(colors.muted, "╭────────────────────────────── signal workspace ──────────────────────────────╮"),
98
+ mark,
99
+ ` ${title} ${color(colors.muted, `v${version}`)} ${signal}`,
100
+ ` ${color(colors.muted, "route the work · keep provider credentials local · ctrl+p commands")}`,
101
+ color(colors.muted, "╰────────────────────────────────────────────────────────────────────────────────╯"),
102
+ ].join("\n");
88
103
  }
89
104
 
90
105
  export function box(title: string, content: string, width = 76): string {
91
106
  const innerWidth = Math.max(20, width - 4);
92
- const top = `╭─ ${color(colors.cyan, title)} ${"─".repeat(Math.max(0, innerWidth - title.length - 3))}╮`;
93
- const body = content.split("\n").map((line) => `│ ${pad(line, innerWidth)} │`);
107
+ const top = `╭─ ${color(colors.orange, title)} ${color(colors.muted, "─".repeat(Math.max(0, innerWidth - title.length - 3)))}╮`;
108
+ const body = content.split("\n").map((line) => `│ ${color(colors.blue, "▎")} ${pad(line, innerWidth - 2)} │`);
94
109
  return [top, ...body, `╰${"─".repeat(width - 2)}╯`].join("\n");
95
110
  }
96
111
 
97
112
  export function statusBar(provider: string, model: string, mode: string): string {
98
- return color(colors.muted, `[${provider || "not configured"}] [${model || "not configured"}] [${mode}]`);
113
+ const providerLabel = provider || "not configured";
114
+ const modelLabel = model || "not configured";
115
+ return [
116
+ color(colors.orange, "◆"), color(colors.text, providerLabel),
117
+ color(colors.muted, "/"), color(colors.blue, modelLabel),
118
+ color(colors.muted, "·"), color(colors.cyan, mode),
119
+ ].join(" ");
99
120
  }
100
121
 
101
122
  export function spinnerFrame(index: number, message: string): string {
102
- return `${color(colors.cyan, frames[index % frames.length])} ${message}`;
123
+ return `${color(colors.blue, frames[index % frames.length])} ${color(colors.muted, "stream")} ${message}`;
103
124
  }
104
125
 
105
126
  export function formatEvent(event: ChatEvent): string {
106
127
  if (event.type === "text") return event.text;
107
- if (event.type === "reasoning") return color(colors.muted, event.text);
128
+ if (event.type === "reasoning") return `${color(colors.blue, "·")} ${color(colors.muted, event.text)}`;
108
129
  if (event.type === "error") return color(colors.red, event.message);
109
130
  if (event.type === "finish") return "";
110
- if (event.type === "start") return color(colors.muted, `Connected to ${event.provider} / ${event.model}`);
111
- return color(colors.blue, `${event.name}: ${JSON.stringify(event.input)}`);
131
+ if (event.type === "start") return `${color(colors.blue, "◆")} ${color(colors.text, "connected")} ${color(colors.muted, `${event.provider} / ${event.model}`)}`;
132
+ return `${color(colors.orange, "→")} ${color(colors.blue, event.name)} ${color(colors.muted, JSON.stringify(event.input))}`;
112
133
  }
113
134
 
114
135
  export function suggestions(input: string, values: string[], limit = 8): string[] {