@cdoing/opentuicli 0.1.21 → 0.1.26

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/src/index.ts DELETED
@@ -1,50 +0,0 @@
1
- /**
2
- * cdoing-tui — OpenTUI-based terminal interface for cdoing agent
3
- *
4
- * This is the advanced TUI (like opencode) using @opentui/react for
5
- * a rich, interactive terminal experience with:
6
- * - Split panes (chat + file preview)
7
- * - Dialog system (model picker, session list, command palette)
8
- * - Theme support
9
- * - Keyboard-driven navigation
10
- *
11
- * The existing @cdoing/cli uses Ink (React) for a simpler TUI.
12
- * This package provides the opencode-style experience.
13
- */
14
-
15
- import { Command } from "commander";
16
- import { getDefaultModel, getRegisteredProviders } from "@cdoing/ai";
17
-
18
- const program = new Command();
19
-
20
- program
21
- .name("cdoing-tui")
22
- .description("OpenTUI-based terminal interface for cdoing agent")
23
- .version("0.1.0")
24
- .option("-m, --model <model>", "Model name")
25
- .option("-p, --provider <provider>", "AI provider", "anthropic")
26
- .option("--api-key <key>", "API key")
27
- .option("--base-url <url>", "Base URL for custom providers")
28
- .option("-d, --dir <directory>", "Working directory", process.cwd())
29
- .option("--mode <mode>", "Permission mode: ask, auto-edit, auto", "ask")
30
- .option("-r, --resume <id>", "Resume conversation by ID")
31
- .option("-c, --continue", "Continue most recent conversation")
32
- .option("--theme <theme>", "Theme: dark, light, auto", "dark")
33
- .argument("[prompt]", "Initial prompt")
34
- .action(async (prompt, opts) => {
35
- const { startTUI } = await import("./app");
36
- await startTUI({
37
- prompt,
38
- provider: opts.provider,
39
- model: opts.model || getDefaultModel(opts.provider),
40
- apiKey: opts.apiKey,
41
- baseUrl: opts.baseUrl,
42
- workingDir: opts.dir,
43
- mode: opts.mode,
44
- resume: opts.resume,
45
- continue: opts.continue,
46
- theme: opts.theme,
47
- });
48
- });
49
-
50
- program.parse();
@@ -1,262 +0,0 @@
1
- /**
2
- * Autocomplete engine — slash commands, @mentions, path completion, tool subcommands
3
- *
4
- * Ported from @cdoing/cli UserInput.tsx logic for the OpenTUI terminal.
5
- */
6
-
7
- import * as fs from "fs";
8
- import * as path from "path";
9
-
10
- // ── Slash Commands ────────────────────────────────────────
11
-
12
- export interface SlashCommand {
13
- name: string;
14
- description: string;
15
- }
16
-
17
- export const SLASH_COMMANDS: SlashCommand[] = [
18
- { name: "/help", description: "Show available commands" },
19
- { name: "/clear", description: "Clear chat history" },
20
- { name: "/new", description: "Start new conversation" },
21
- { name: "/model", description: "Show/change model" },
22
- { name: "/provider", description: "Show/change provider" },
23
- { name: "/mode", description: "Cycle permission mode" },
24
- { name: "/usage", description: "Show token usage & cost" },
25
- { name: "/compact", description: "Compress context" },
26
- { name: "/config", description: "Show/set configuration" },
27
- { name: "/dir", description: "Change working directory" },
28
- { name: "/history", description: "Browse past sessions" },
29
- { name: "/ls", description: "List conversations" },
30
- { name: "/resume", description: "Resume conversation by ID" },
31
- { name: "/view", description: "View a conversation" },
32
- { name: "/fork", description: "Fork current conversation" },
33
- { name: "/delete", description: "Delete a conversation" },
34
- { name: "/plan", description: "Plan mode (approve/reject/show/off)" },
35
- { name: "/plan approve", description: "Approve plan and start building" },
36
- { name: "/plan reject", description: "Reject plan" },
37
- { name: "/plan show", description: "Show current plan" },
38
- { name: "/plan off", description: "Cancel plan mode" },
39
- { name: "/tasks", description: "Show active tasks" },
40
- { name: "/memory", description: "Show agent memory" },
41
- { name: "/permissions", description: "Show permission rules" },
42
- { name: "/hooks", description: "Show configured hooks" },
43
- { name: "/rules", description: "Show project rules" },
44
- { name: "/mcp", description: "MCP server management" },
45
- { name: "/context", description: "Show context providers" },
46
- { name: "/effort", description: "Set effort level" },
47
- { name: "/theme", description: "Switch theme" },
48
- { name: "/bg", description: "Run in background" },
49
- { name: "/jobs", description: "Show background jobs" },
50
- { name: "/login", description: "OAuth login" },
51
- { name: "/logout", description: "OAuth logout" },
52
- { name: "/setup", description: "Run setup wizard" },
53
- { name: "/doctor", description: "Check system health" },
54
- { name: "/init", description: "Initialize project config" },
55
- { name: "/exit", description: "Quit the TUI" },
56
- { name: "/quit", description: "Quit the TUI" },
57
- { name: "/btw", description: "Ask without adding to history" },
58
- { name: "/auth-status", description: "Show authentication status" },
59
- { name: "/queue", description: "Show message queue" },
60
- ];
61
-
62
- // ── @Mention Providers ────────────────────────────────────
63
-
64
- export interface MentionProvider {
65
- trigger: string;
66
- description: string;
67
- }
68
-
69
- export const MENTION_PROVIDERS: MentionProvider[] = [
70
- { trigger: "@terminal", description: "Recent terminal output" },
71
- { trigger: "@url", description: "Fetch URL content" },
72
- { trigger: "@tree", description: "Project file tree" },
73
- { trigger: "@codebase", description: "Full codebase context" },
74
- { trigger: "@clip", description: "Clipboard content" },
75
- { trigger: "@file", description: "Include a file" },
76
- ];
77
-
78
- // ── Tool Subcommands ──────────────────────────────────────
79
-
80
- const TOOL_SUBCOMMANDS: Record<string, string[]> = {
81
- npm: ["install", "run", "test", "start", "build", "init", "publish", "uninstall", "update", "ls", "audit", "ci"],
82
- yarn: ["install", "add", "remove", "run", "build", "test", "start", "dev", "upgrade", "info", "why"],
83
- pnpm: ["install", "add", "remove", "run", "build", "test", "dev", "update", "store"],
84
- bun: ["install", "add", "remove", "run", "build", "test", "dev", "init", "create"],
85
- git: ["status", "add", "commit", "push", "pull", "fetch", "checkout", "branch", "merge", "rebase", "log", "diff", "stash", "reset", "clone", "remote", "tag", "cherry-pick", "bisect", "show", "blame", "reflog"],
86
- docker: ["build", "run", "exec", "ps", "images", "pull", "push", "stop", "rm", "logs", "compose"],
87
- python: ["-m", "-c", "--version", "manage.py"],
88
- python3: ["-m", "-c", "--version", "manage.py"],
89
- pip: ["install", "uninstall", "freeze", "list", "show"],
90
- cargo: ["build", "run", "test", "check", "clippy", "fmt", "new", "init", "add", "publish"],
91
- go: ["build", "run", "test", "get", "mod", "fmt", "vet", "install", "generate"],
92
- make: ["build", "test", "clean", "install", "all"],
93
- kubectl: ["get", "describe", "apply", "delete", "logs", "exec", "port-forward", "scale"],
94
- gh: ["pr", "issue", "repo", "run", "release", "api", "auth", "browse"],
95
- turbo: ["build", "dev", "test", "lint", "run"],
96
- npx: ["tsc", "ts-node", "eslint", "prettier", "jest", "vitest", "playwright"],
97
- };
98
-
99
- // ── Suggestion Types ──────────────────────────────────────
100
-
101
- export interface Suggestion {
102
- text: string;
103
- description?: string;
104
- type: "command" | "mention" | "file" | "subcommand";
105
- }
106
-
107
- // ── Autocomplete Logic ────────────────────────────────────
108
-
109
- export function getCompletions(input: string, workingDir: string): Suggestion[] {
110
- if (!input) return [];
111
-
112
- // Slash commands
113
- if (input.startsWith("/")) {
114
- return SLASH_COMMANDS
115
- .filter((c) => c.name.startsWith(input))
116
- .map((c) => ({ text: c.name, description: c.description, type: "command" as const }));
117
- }
118
-
119
- // @mentions
120
- if (input.startsWith("@") || input.includes(" @")) {
121
- const atIdx = input.lastIndexOf("@");
122
- const query = input.substring(atIdx);
123
-
124
- const results: Suggestion[] = [];
125
-
126
- // Provider matches
127
- for (const p of MENTION_PROVIDERS) {
128
- if (p.trigger.startsWith(query)) {
129
- results.push({ text: p.trigger, description: p.description, type: "mention" });
130
- }
131
- }
132
-
133
- // File matches for @file or bare @
134
- if (query === "@" || query.startsWith("@file ") || query.startsWith("@f")) {
135
- const fileQuery = query.startsWith("@file ") ? query.substring(6) : "";
136
- const files = getProjectFiles(workingDir, fileQuery);
137
- for (const f of files.slice(0, 10)) {
138
- results.push({ text: `@file ${f}`, description: "", type: "file" });
139
- }
140
- }
141
-
142
- return results;
143
- }
144
-
145
- // Tool subcommands (e.g. "npm " → show npm subcommands)
146
- const parts = input.split(" ");
147
- if (parts.length >= 1) {
148
- const tool = parts[0];
149
- const subcommands = TOOL_SUBCOMMANDS[tool];
150
- if (subcommands && parts.length <= 2) {
151
- const sub = parts[1] || "";
152
- return subcommands
153
- .filter((s) => s.startsWith(sub))
154
- .map((s) => ({ text: `${tool} ${s}`, description: "", type: "subcommand" as const }));
155
- }
156
- }
157
-
158
- // Path completion for shell commands (cd, ls, cat, etc.)
159
- if (parts.length >= 2) {
160
- const pathResults = getPathCompletions(input, workingDir);
161
- if (pathResults.length > 0) {
162
- const prefix = parts.slice(0, -1).join(" ") + " ";
163
- return pathResults.map((p) => ({
164
- text: prefix + p,
165
- description: p.endsWith("/") ? "directory" : "file",
166
- type: "file" as const,
167
- }));
168
- }
169
- }
170
-
171
- return [];
172
- }
173
-
174
- // ── Ghost Text ────────────────────────────────────────────
175
-
176
- export function getGhostText(input: string, workingDir: string): string {
177
- if (!input) return "";
178
-
179
- // Slash command ghost
180
- if (input.startsWith("/")) {
181
- const match = SLASH_COMMANDS.find((c) => c.name.startsWith(input) && c.name !== input);
182
- return match ? match.name.substring(input.length) : "";
183
- }
184
-
185
- // @mention ghost
186
- const atIdx = input.lastIndexOf("@");
187
- if (atIdx >= 0 && atIdx === input.length - input.substring(atIdx).length) {
188
- const query = input.substring(atIdx);
189
- const match = MENTION_PROVIDERS.find((p) => p.trigger.startsWith(query) && p.trigger !== query);
190
- return match ? match.trigger.substring(query.length) : "";
191
- }
192
-
193
- return "";
194
- }
195
-
196
- // ── Path Completion ───────────────────────────────────────
197
-
198
- export function getPathCompletions(input: string, workingDir: string): string[] {
199
- // Detect path context: commands like cd, ls, cat, etc.
200
- const pathCommands = ["cd", "ls", "cat", "head", "tail", "less", "more", "vim", "nano", "code", "open", "rm", "cp", "mv", "mkdir", "touch", "chmod"];
201
- const parts = input.split(" ");
202
- if (parts.length < 2) return [];
203
-
204
- const cmd = parts[0];
205
- if (!pathCommands.includes(cmd)) return [];
206
-
207
- const partial = parts[parts.length - 1] || "";
208
- return readPathEntries(workingDir, partial);
209
- }
210
-
211
- function readPathEntries(workingDir: string, partial: string): string[] {
212
- try {
213
- const dir = partial.includes("/")
214
- ? path.resolve(workingDir, partial.substring(0, partial.lastIndexOf("/") + 1))
215
- : workingDir;
216
- const prefix = partial.includes("/") ? partial.substring(partial.lastIndexOf("/") + 1) : partial;
217
-
218
- if (!fs.existsSync(dir) || !fs.statSync(dir).isDirectory()) return [];
219
-
220
- const entries = fs.readdirSync(dir, { withFileTypes: true });
221
- const base = partial.includes("/") ? partial.substring(0, partial.lastIndexOf("/") + 1) : "";
222
-
223
- return entries
224
- .filter((e) => !e.name.startsWith(".") || partial.startsWith("."))
225
- .filter((e) => e.name.toLowerCase().startsWith(prefix.toLowerCase()))
226
- .sort((a, b) => {
227
- if (a.isDirectory() !== b.isDirectory()) return a.isDirectory() ? -1 : 1;
228
- return a.name.localeCompare(b.name);
229
- })
230
- .slice(0, 20)
231
- .map((e) => base + e.name + (e.isDirectory() ? "/" : ""));
232
- } catch {
233
- return [];
234
- }
235
- }
236
-
237
- function getProjectFiles(workingDir: string, query: string): string[] {
238
- const results: string[] = [];
239
- const lower = query.toLowerCase();
240
- const skipDirs = new Set(["node_modules", ".git", "dist", "build", "__pycache__", ".cache", "coverage"]);
241
-
242
- const walk = (dir: string, rel: string, depth: number) => {
243
- if (depth > 3 || results.length >= 15) return;
244
- try {
245
- const entries = fs.readdirSync(path.join(workingDir, dir), { withFileTypes: true });
246
- for (const e of entries) {
247
- if (e.name.startsWith(".") || skipDirs.has(e.name)) continue;
248
- if (results.length >= 15) break;
249
- const p = rel ? `${rel}/${e.name}` : e.name;
250
- if (e.isDirectory()) {
251
- if (!query || p.toLowerCase().includes(lower)) results.push(p + "/");
252
- walk(path.join(dir, e.name), p, depth + 1);
253
- } else {
254
- if (!query || p.toLowerCase().includes(lower)) results.push(p);
255
- }
256
- }
257
- } catch { /* skip */ }
258
- };
259
-
260
- walk("", "", 0);
261
- return results;
262
- }
@@ -1,98 +0,0 @@
1
- /**
2
- * Context Provider Expansion — resolves @mentions in user messages
3
- *
4
- * Detects @terminal, @url, @tree, @codebase, @clip, @file triggers
5
- * and expands them into the actual content before sending to the agent.
6
- */
7
-
8
- import {
9
- ContextProviderRegistry,
10
- TerminalContextProvider,
11
- UrlContextProvider,
12
- TreeContextProvider,
13
- CodebaseContextProvider,
14
- ClipboardContextProvider,
15
- FileIncludeContextProvider,
16
- } from "@cdoing/core";
17
-
18
- let registry: ContextProviderRegistry | null = null;
19
- let terminalProvider: TerminalContextProvider | null = null;
20
-
21
- function getRegistry(): ContextProviderRegistry {
22
- if (!registry) {
23
- registry = new ContextProviderRegistry();
24
- terminalProvider = new TerminalContextProvider();
25
- registry.register(terminalProvider);
26
- registry.register(new UrlContextProvider());
27
- registry.register(new TreeContextProvider());
28
- registry.register(new CodebaseContextProvider());
29
- registry.register(new ClipboardContextProvider());
30
- registry.register(new FileIncludeContextProvider());
31
- }
32
- return registry;
33
- }
34
-
35
- /** Update terminal provider with recent shell output */
36
- export function pushTerminalOutput(output: string): void {
37
- getRegistry();
38
- if (terminalProvider && typeof (terminalProvider as any).push === "function") {
39
- (terminalProvider as any).push(output);
40
- }
41
- }
42
-
43
- /** Known @mention triggers */
44
- const TRIGGERS = ["@terminal", "@url", "@tree", "@codebase", "@clip", "@file"];
45
-
46
- /**
47
- * Resolve all @mention providers in a message.
48
- * Returns the expanded message with provider content appended.
49
- */
50
- export async function resolveContextProviders(
51
- message: string,
52
- workingDir: string
53
- ): Promise<string> {
54
- const reg = getRegistry();
55
- let expandedMessage = message;
56
- const appendSections: string[] = [];
57
-
58
- for (const trigger of TRIGGERS) {
59
- const idx = expandedMessage.indexOf(trigger);
60
- if (idx === -1) continue;
61
-
62
- // Extract the trigger + argument
63
- const afterTrigger = expandedMessage.substring(idx + trigger.length);
64
- const endIdx = afterTrigger.search(/\s@|\n|$/);
65
- const arg = afterTrigger.substring(0, endIdx === -1 ? afterTrigger.length : endIdx).trim();
66
-
67
- // Remove trigger from message
68
- const fullTrigger = trigger + (arg ? " " + arg : "");
69
- expandedMessage = expandedMessage.replace(fullTrigger, "").trim();
70
-
71
- // Resolve
72
- const providerName = trigger.substring(1); // strip @
73
- const provider = reg.get(providerName);
74
- if (!provider) continue;
75
-
76
- try {
77
- const result = await provider.resolve(arg, { workingDir });
78
- if (result) {
79
- appendSections.push(`--- ${trigger} ---\n${result}`);
80
- }
81
- } catch {
82
- // Silently skip failed providers
83
- }
84
- }
85
-
86
- if (appendSections.length > 0) {
87
- expandedMessage = expandedMessage + "\n\n" + appendSections.join("\n\n");
88
- }
89
-
90
- return expandedMessage;
91
- }
92
-
93
- /**
94
- * Check if a message contains any @mention triggers.
95
- */
96
- export function hasContextMentions(message: string): boolean {
97
- return TRIGGERS.some((t) => message.includes(t));
98
- }
@@ -1,164 +0,0 @@
1
- /**
2
- * Conversation History Manager for TUI
3
- *
4
- * Saves conversations to ~/.cdoing/conversations/ as JSON files.
5
- * Mirrors the base CLI implementation for full compatibility.
6
- */
7
-
8
- import * as fs from "fs";
9
- import * as path from "path";
10
- import * as os from "os";
11
-
12
- const CONV_DIR = path.join(os.homedir(), ".cdoing", "conversations");
13
-
14
- export interface ChatMessage {
15
- role: "user" | "assistant" | "tool";
16
- content: string;
17
- toolName?: string;
18
- timestamp: number;
19
- }
20
-
21
- export interface Conversation {
22
- id: string;
23
- title: string;
24
- createdAt: number;
25
- updatedAt: number;
26
- provider: string;
27
- model: string;
28
- messages: ChatMessage[];
29
- }
30
-
31
- function ensureDir(): void {
32
- if (!fs.existsSync(CONV_DIR)) fs.mkdirSync(CONV_DIR, { recursive: true });
33
- }
34
-
35
- function generateId(): string {
36
- const now = Date.now().toString(36);
37
- const rand = Math.random().toString(36).slice(2, 6);
38
- return `${now}-${rand}`;
39
- }
40
-
41
- function deriveTitle(message: string): string {
42
- const clean = message.replace(/\n/g, " ").trim();
43
- return clean.length > 60 ? clean.substring(0, 57) + "..." : clean;
44
- }
45
-
46
- export function createConversation(provider: string, model: string): Conversation {
47
- ensureDir();
48
- return {
49
- id: generateId(),
50
- title: "New conversation",
51
- createdAt: Date.now(),
52
- updatedAt: Date.now(),
53
- provider,
54
- model,
55
- messages: [],
56
- };
57
- }
58
-
59
- export function addMessage(
60
- conv: Conversation,
61
- role: "user" | "assistant" | "tool",
62
- content: string,
63
- toolName?: string
64
- ): void {
65
- conv.messages.push({ role, content, timestamp: Date.now(), toolName });
66
- conv.updatedAt = Date.now();
67
-
68
- if (role === "user" && conv.title === "New conversation") {
69
- conv.title = deriveTitle(content);
70
- }
71
-
72
- saveConversation(conv);
73
- }
74
-
75
- export function saveConversation(conv: Conversation): void {
76
- ensureDir();
77
- const filePath = path.join(CONV_DIR, `${conv.id}.json`);
78
- fs.writeFileSync(filePath, JSON.stringify(conv, null, 2), "utf-8");
79
- }
80
-
81
- export function loadConversation(id: string): Conversation | null {
82
- // Support partial IDs
83
- ensureDir();
84
- const files = fs.readdirSync(CONV_DIR).filter((f) => f.endsWith(".json"));
85
- const match = files.find((f) => f.startsWith(id) || f.replace(".json", "") === id);
86
- if (!match) return null;
87
-
88
- const filePath = path.join(CONV_DIR, match);
89
- try {
90
- return JSON.parse(fs.readFileSync(filePath, "utf-8"));
91
- } catch {
92
- return null;
93
- }
94
- }
95
-
96
- export function listConversations(): Conversation[] {
97
- ensureDir();
98
- const files = fs.readdirSync(CONV_DIR).filter((f) => f.endsWith(".json"));
99
- const convs: Conversation[] = [];
100
-
101
- for (const file of files) {
102
- try {
103
- const data = JSON.parse(fs.readFileSync(path.join(CONV_DIR, file), "utf-8"));
104
- convs.push(data);
105
- } catch {}
106
- }
107
-
108
- return convs.sort((a, b) => b.updatedAt - a.updatedAt);
109
- }
110
-
111
- export function loadLastConversation(): Conversation | null {
112
- const all = listConversations();
113
- return all.length > 0 ? all[0] : null;
114
- }
115
-
116
- export function deleteConversation(id: string): boolean {
117
- ensureDir();
118
- const files = fs.readdirSync(CONV_DIR).filter((f) => f.endsWith(".json"));
119
- const match = files.find((f) => f.startsWith(id) || f.replace(".json", "") === id);
120
- if (match) {
121
- fs.unlinkSync(path.join(CONV_DIR, match));
122
- return true;
123
- }
124
- return false;
125
- }
126
-
127
- export function forkConversation(idOrConv: string | Conversation): Conversation | null {
128
- const original =
129
- typeof idOrConv === "string" ? loadConversation(idOrConv) : idOrConv;
130
- if (!original) return null;
131
-
132
- ensureDir();
133
- const forked: Conversation = {
134
- ...original,
135
- id: generateId(),
136
- title: `Fork of: ${original.title}`,
137
- createdAt: Date.now(),
138
- updatedAt: Date.now(),
139
- messages: original.messages.map((m) => ({ ...m })),
140
- };
141
- saveConversation(forked);
142
- return forked;
143
- }
144
-
145
- export function updateConversationTitle(id: string, title: string): void {
146
- const conv = loadConversation(id);
147
- if (!conv) return;
148
- conv.title = title.length > 80 ? title.substring(0, 77) + "..." : title;
149
- conv.updatedAt = Date.now();
150
- saveConversation(conv);
151
- }
152
-
153
- export function formatRelativeDate(timestamp: number): string {
154
- const diff = Date.now() - timestamp;
155
- const seconds = Math.floor(diff / 1000);
156
- if (seconds < 60) return "just now";
157
- const minutes = Math.floor(seconds / 60);
158
- if (minutes < 60) return `${minutes}m ago`;
159
- const hours = Math.floor(minutes / 60);
160
- if (hours < 24) return `${hours}h ago`;
161
- const days = Math.floor(hours / 24);
162
- if (days < 30) return `${days}d ago`;
163
- return new Date(timestamp).toLocaleDateString();
164
- }
@@ -1,15 +0,0 @@
1
- /**
2
- * Terminal Title — set/reset the terminal window title via escape sequences
3
- */
4
-
5
- export function setTerminalTitle(title: string): void {
6
- if (process.stdout.isTTY) {
7
- process.stdout.write(`\x1b]0;${title}\x07`);
8
- }
9
- }
10
-
11
- export function resetTerminalTitle(): void {
12
- if (process.stdout.isTTY) {
13
- process.stdout.write(`\x1b]0;\x07`);
14
- }
15
- }