@cdoing/opentuicli 0.1.2
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/dist/index.js +48 -0
- package/dist/index.js.map +7 -0
- package/esbuild.config.cjs +44 -0
- package/package.json +34 -0
- package/src/app.tsx +566 -0
- package/src/components/dialog-command.tsx +204 -0
- package/src/components/dialog-help.tsx +227 -0
- package/src/components/dialog-model.tsx +93 -0
- package/src/components/dialog-status.tsx +122 -0
- package/src/components/dialog-theme.tsx +292 -0
- package/src/components/input-area.tsx +318 -0
- package/src/components/loading-spinner.tsx +28 -0
- package/src/components/message-list.tsx +338 -0
- package/src/components/permission-prompt.tsx +71 -0
- package/src/components/session-browser.tsx +220 -0
- package/src/components/session-footer.tsx +30 -0
- package/src/components/session-header.tsx +39 -0
- package/src/components/setup-wizard.tsx +463 -0
- package/src/components/sidebar.tsx +130 -0
- package/src/components/status-bar.tsx +76 -0
- package/src/components/toast.tsx +139 -0
- package/src/context/sdk.tsx +40 -0
- package/src/context/theme.tsx +532 -0
- package/src/index.ts +50 -0
- package/src/lib/autocomplete.ts +258 -0
- package/src/lib/context-providers.ts +98 -0
- package/src/lib/history.ts +164 -0
- package/src/lib/terminal-title.ts +15 -0
- package/src/routes/home.tsx +148 -0
- package/src/routes/session.tsx +1186 -0
- package/tsconfig.json +23 -0
|
@@ -0,0 +1,338 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* MessageList — renders chat messages with tool calls, streaming, and markdown
|
|
3
|
+
*
|
|
4
|
+
* Uses a custom inline markdown renderer for assistant messages (matching the CLI's
|
|
5
|
+
* RenderMarkdown approach) with OpenTUI's <markdown> component for fenced code blocks.
|
|
6
|
+
* The scrollbox is managed by the parent (session.tsx) to ensure proper flex height
|
|
7
|
+
* calculation (matching OpenCode's pattern).
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import { TextAttributes } from "@opentui/core";
|
|
11
|
+
import { useTheme, type Theme } from "../context/theme";
|
|
12
|
+
|
|
13
|
+
// ── Types ──────────────────────────────────────────────
|
|
14
|
+
|
|
15
|
+
export interface Message {
|
|
16
|
+
id: string;
|
|
17
|
+
role: "user" | "assistant" | "system" | "tool";
|
|
18
|
+
content: string;
|
|
19
|
+
toolName?: string;
|
|
20
|
+
toolStatus?: "running" | "done" | "error";
|
|
21
|
+
isError?: boolean;
|
|
22
|
+
timestamp: number;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
// ── Tool Icons ─────────────────────────────────────────
|
|
26
|
+
|
|
27
|
+
const TOOL_ICONS: Record<string, string> = {
|
|
28
|
+
file_read: "📖", file_write: "✏️", file_edit: "🔧", multi_edit: "🔧",
|
|
29
|
+
shell_exec: "💻", file_run: "▶", glob_search: "🔍", grep_search: "🔎",
|
|
30
|
+
codebase_search: "🔎", web_fetch: "🌐", web_search: "🔮", sub_agent: "🤖",
|
|
31
|
+
todo: "📋", list_dir: "📁", view_diff: "📊", view_repo_map: "🗺️",
|
|
32
|
+
code_verify: "✅", system_info: "ℹ️", ast_edit: "🌳", notebook_edit: "📓",
|
|
33
|
+
};
|
|
34
|
+
|
|
35
|
+
// ── Inline Markdown Helpers ──────────────────────────────
|
|
36
|
+
|
|
37
|
+
/** Strip markdown inline syntax markers: **bold** → bold, *italic* → italic, `code` → code */
|
|
38
|
+
function stripInlineMarkdown(text: string): string {
|
|
39
|
+
return text
|
|
40
|
+
.replace(/`([^`]+)`/g, "$1")
|
|
41
|
+
.replace(/\*\*([^*]+)\*\*/g, "$1")
|
|
42
|
+
.replace(/(?<!\w)\*([^*]+)\*(?!\w)/g, "$1");
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
// ── Custom Markdown Renderer ─────────────────────────────
|
|
46
|
+
// Renders markdown content using OpenTUI primitives with proper styling.
|
|
47
|
+
// Strips markdown syntax (##, **, *, `, ---) and renders styled text.
|
|
48
|
+
// Uses <markdown> component only for fenced code blocks (syntax highlighting).
|
|
49
|
+
|
|
50
|
+
function RenderMarkdown(props: { text: string; theme: Theme }) {
|
|
51
|
+
const t = props.theme;
|
|
52
|
+
const { syntaxStyle } = useTheme();
|
|
53
|
+
const lines = props.text.split("\n");
|
|
54
|
+
|
|
55
|
+
const rendered: React.ReactNode[] = [];
|
|
56
|
+
let i = 0;
|
|
57
|
+
|
|
58
|
+
while (i < lines.length) {
|
|
59
|
+
const line = lines[i];
|
|
60
|
+
|
|
61
|
+
// ── Fenced code block — use OpenTUI <markdown> for syntax highlighting ──
|
|
62
|
+
if (line.startsWith("```")) {
|
|
63
|
+
const codeLines: string[] = [line];
|
|
64
|
+
i++;
|
|
65
|
+
while (i < lines.length) {
|
|
66
|
+
codeLines.push(lines[i]);
|
|
67
|
+
if (lines[i].startsWith("```")) {
|
|
68
|
+
i++;
|
|
69
|
+
break;
|
|
70
|
+
}
|
|
71
|
+
i++;
|
|
72
|
+
}
|
|
73
|
+
const codeBlock = codeLines.join("\n");
|
|
74
|
+
rendered.push(
|
|
75
|
+
<box key={`code-${i}`} marginY={0}>
|
|
76
|
+
<markdown
|
|
77
|
+
syntaxStyle={syntaxStyle}
|
|
78
|
+
streaming={false}
|
|
79
|
+
content={codeBlock}
|
|
80
|
+
conceal={true}
|
|
81
|
+
/>
|
|
82
|
+
</box>
|
|
83
|
+
);
|
|
84
|
+
continue;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
// ── Headers ──
|
|
88
|
+
if (line.startsWith("### ")) {
|
|
89
|
+
rendered.push(
|
|
90
|
+
<text key={i} fg={t.info} attributes={TextAttributes.BOLD}>
|
|
91
|
+
{` ▸ ${stripInlineMarkdown(line.slice(4))}`}
|
|
92
|
+
</text>
|
|
93
|
+
);
|
|
94
|
+
i++;
|
|
95
|
+
continue;
|
|
96
|
+
}
|
|
97
|
+
if (line.startsWith("## ")) {
|
|
98
|
+
rendered.push(
|
|
99
|
+
<text key={i} fg={t.primary} attributes={TextAttributes.BOLD}>
|
|
100
|
+
{` ▸▸ ${stripInlineMarkdown(line.slice(3))}`}
|
|
101
|
+
</text>
|
|
102
|
+
);
|
|
103
|
+
i++;
|
|
104
|
+
continue;
|
|
105
|
+
}
|
|
106
|
+
if (line.startsWith("# ")) {
|
|
107
|
+
rendered.push(
|
|
108
|
+
<text key={i} fg={t.primary} attributes={TextAttributes.BOLD}>
|
|
109
|
+
{`▸▸▸ ${stripInlineMarkdown(line.slice(2))}`}
|
|
110
|
+
</text>
|
|
111
|
+
);
|
|
112
|
+
i++;
|
|
113
|
+
continue;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
// ── Horizontal rule ──
|
|
117
|
+
if (/^---+$/.test(line) || /^===+$/.test(line) || /^\*\*\*+$/.test(line)) {
|
|
118
|
+
rendered.push(
|
|
119
|
+
<text key={i} fg={t.textDim}>{"─".repeat(40)}</text>
|
|
120
|
+
);
|
|
121
|
+
i++;
|
|
122
|
+
continue;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
// ── Bullet list ──
|
|
126
|
+
const bulletMatch = line.match(/^(\s*)[-*] (.*)/);
|
|
127
|
+
if (bulletMatch) {
|
|
128
|
+
const indent = bulletMatch[1] || "";
|
|
129
|
+
const content = stripInlineMarkdown(bulletMatch[2]);
|
|
130
|
+
rendered.push(
|
|
131
|
+
<text key={i}>{`${indent}● ${content}`}</text>
|
|
132
|
+
);
|
|
133
|
+
i++;
|
|
134
|
+
continue;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
// ── Numbered list ──
|
|
138
|
+
const numMatch = line.match(/^(\s*)(\d+)\. (.*)/);
|
|
139
|
+
if (numMatch) {
|
|
140
|
+
const content = stripInlineMarkdown(numMatch[3]);
|
|
141
|
+
rendered.push(
|
|
142
|
+
<text key={i}>{`${numMatch[1]}${numMatch[2]}. ${content}`}</text>
|
|
143
|
+
);
|
|
144
|
+
i++;
|
|
145
|
+
continue;
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
// ── Blockquote ──
|
|
149
|
+
if (line.startsWith("> ")) {
|
|
150
|
+
rendered.push(
|
|
151
|
+
<text key={i} fg={t.textMuted}>{`│ ${stripInlineMarkdown(line.slice(2))}`}</text>
|
|
152
|
+
);
|
|
153
|
+
i++;
|
|
154
|
+
continue;
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
// ── Empty line ──
|
|
158
|
+
if (!line.trim()) {
|
|
159
|
+
rendered.push(<text key={i}>{" "}</text>);
|
|
160
|
+
i++;
|
|
161
|
+
continue;
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
// ── Plain text — strip markdown syntax ──
|
|
165
|
+
rendered.push(
|
|
166
|
+
<text key={i}>{stripInlineMarkdown(line)}</text>
|
|
167
|
+
);
|
|
168
|
+
i++;
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
return <box flexDirection="column">{rendered}</box>;
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
// ── Component ──────────────────────────────────────────
|
|
175
|
+
|
|
176
|
+
function formatTimestamp(ts: number): string {
|
|
177
|
+
const d = new Date(ts);
|
|
178
|
+
let h = d.getHours();
|
|
179
|
+
const m = d.getMinutes();
|
|
180
|
+
const ampm = h >= 12 ? "PM" : "AM";
|
|
181
|
+
h = h % 12 || 12;
|
|
182
|
+
return `${h}:${m.toString().padStart(2, "0")} ${ampm}`;
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
/**
|
|
186
|
+
* Renders message content only (no scrollbox wrapper).
|
|
187
|
+
* Parent should wrap this in a <scrollbox> for proper flex height.
|
|
188
|
+
*/
|
|
189
|
+
export function MessageList(props: {
|
|
190
|
+
messages: Message[];
|
|
191
|
+
streamingText?: string;
|
|
192
|
+
isStreaming?: boolean;
|
|
193
|
+
showTimestamps?: boolean;
|
|
194
|
+
}) {
|
|
195
|
+
const { theme } = useTheme();
|
|
196
|
+
const t = theme;
|
|
197
|
+
|
|
198
|
+
return (
|
|
199
|
+
<>
|
|
200
|
+
{/* Empty state */}
|
|
201
|
+
{props.messages.length === 0 && !props.isStreaming && (
|
|
202
|
+
<box paddingX={2} paddingY={1}>
|
|
203
|
+
<text fg={t.textMuted}>
|
|
204
|
+
{"Type a message to start chatting. Use / for commands, @ for context."}
|
|
205
|
+
</text>
|
|
206
|
+
</box>
|
|
207
|
+
)}
|
|
208
|
+
|
|
209
|
+
{/* Messages */}
|
|
210
|
+
{props.messages.map((msg) => {
|
|
211
|
+
if (msg.role === "user") {
|
|
212
|
+
return (
|
|
213
|
+
<box key={msg.id} paddingX={1} paddingY={0} flexDirection="row">
|
|
214
|
+
<text fg={t.userText} attributes={TextAttributes.BOLD}>
|
|
215
|
+
{"❯ "}
|
|
216
|
+
</text>
|
|
217
|
+
<text fg={t.userText} flexGrow={1}>{msg.content}</text>
|
|
218
|
+
{props.showTimestamps && msg.timestamp && (
|
|
219
|
+
<text fg={t.textDim}>{` ${formatTimestamp(msg.timestamp)}`}</text>
|
|
220
|
+
)}
|
|
221
|
+
</box>
|
|
222
|
+
);
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
if (msg.role === "assistant") {
|
|
226
|
+
return (
|
|
227
|
+
<box key={msg.id} paddingLeft={1} marginTop={1} flexShrink={0} flexDirection="column">
|
|
228
|
+
<box flexDirection="row">
|
|
229
|
+
<text fg={t.primary} attributes={TextAttributes.BOLD}>
|
|
230
|
+
{"◆ "}
|
|
231
|
+
</text>
|
|
232
|
+
{props.showTimestamps && msg.timestamp && (
|
|
233
|
+
<text fg={t.textDim}>{` ${formatTimestamp(msg.timestamp)}`}</text>
|
|
234
|
+
)}
|
|
235
|
+
</box>
|
|
236
|
+
<box paddingLeft={2}>
|
|
237
|
+
<RenderMarkdown text={msg.content.trim()} theme={t} />
|
|
238
|
+
</box>
|
|
239
|
+
</box>
|
|
240
|
+
);
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
if (msg.role === "system") {
|
|
244
|
+
return (
|
|
245
|
+
<box key={msg.id} paddingX={1} flexDirection="row">
|
|
246
|
+
<text fg={t.systemText} flexGrow={1}>{`⚡ ${msg.content}`}</text>
|
|
247
|
+
{props.showTimestamps && msg.timestamp && (
|
|
248
|
+
<text fg={t.textDim}>{` ${formatTimestamp(msg.timestamp)}`}</text>
|
|
249
|
+
)}
|
|
250
|
+
</box>
|
|
251
|
+
);
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
if (msg.role === "tool") {
|
|
255
|
+
return (
|
|
256
|
+
<ToolCallRow
|
|
257
|
+
key={msg.id}
|
|
258
|
+
name={msg.toolName || "unknown"}
|
|
259
|
+
content={msg.content}
|
|
260
|
+
status={msg.toolStatus || (msg.isError ? "error" : "done")}
|
|
261
|
+
/>
|
|
262
|
+
);
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
return null;
|
|
266
|
+
})}
|
|
267
|
+
|
|
268
|
+
{/* Streaming indicator */}
|
|
269
|
+
{props.isStreaming && (
|
|
270
|
+
<box paddingLeft={1} marginTop={1} flexShrink={0} flexDirection="column">
|
|
271
|
+
<box flexDirection="row">
|
|
272
|
+
<text fg={t.primary} attributes={TextAttributes.BOLD}>
|
|
273
|
+
{"◆ "}
|
|
274
|
+
</text>
|
|
275
|
+
<text fg={t.primary}>{"▊"}</text>
|
|
276
|
+
</box>
|
|
277
|
+
{(props.streamingText || "").trim() && (
|
|
278
|
+
<box paddingLeft={2}>
|
|
279
|
+
<RenderMarkdown text={(props.streamingText || "").trim()} theme={t} />
|
|
280
|
+
</box>
|
|
281
|
+
)}
|
|
282
|
+
</box>
|
|
283
|
+
)}
|
|
284
|
+
</>
|
|
285
|
+
);
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
// ── Tool Call Row ──────────────────────────────────────
|
|
289
|
+
|
|
290
|
+
function ToolCallRow(props: {
|
|
291
|
+
name: string;
|
|
292
|
+
content: string;
|
|
293
|
+
status: "running" | "done" | "error";
|
|
294
|
+
}) {
|
|
295
|
+
const { theme } = useTheme();
|
|
296
|
+
const t = theme;
|
|
297
|
+
|
|
298
|
+
const icon = TOOL_ICONS[props.name] || "⚙️";
|
|
299
|
+
const statusIcon = (() => {
|
|
300
|
+
switch (props.status) {
|
|
301
|
+
case "running": return "⏳";
|
|
302
|
+
case "done": return "✓";
|
|
303
|
+
case "error": return "✗";
|
|
304
|
+
}
|
|
305
|
+
})();
|
|
306
|
+
const statusColor = (() => {
|
|
307
|
+
switch (props.status) {
|
|
308
|
+
case "running": return t.toolRunning;
|
|
309
|
+
case "done": return t.toolDone;
|
|
310
|
+
case "error": return t.toolError;
|
|
311
|
+
}
|
|
312
|
+
})();
|
|
313
|
+
|
|
314
|
+
const shortName = (() => {
|
|
315
|
+
const names: Record<string, string> = {
|
|
316
|
+
file_read: "Read", file_write: "Write", file_edit: "Edit",
|
|
317
|
+
multi_edit: "MultiEdit", shell_exec: "Bash", glob_search: "Search files",
|
|
318
|
+
grep_search: "Search code", web_fetch: "Fetch", sub_agent: "Agent",
|
|
319
|
+
list_dir: "List dir", codebase_search: "Codebase search",
|
|
320
|
+
};
|
|
321
|
+
return names[props.name] || props.name.replace(/_/g, " ");
|
|
322
|
+
})();
|
|
323
|
+
|
|
324
|
+
return (
|
|
325
|
+
<box paddingX={2}>
|
|
326
|
+
<text fg={statusColor}>{`${statusIcon} `}</text>
|
|
327
|
+
<text fg={t.toolText}>{`${icon} ${shortName}`}</text>
|
|
328
|
+
{props.content && (
|
|
329
|
+
<text fg={t.textDim}>{` — ${trimText(props.content, 60)}`}</text>
|
|
330
|
+
)}
|
|
331
|
+
</box>
|
|
332
|
+
);
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
function trimText(s: string, max: number): string {
|
|
336
|
+
const first = s.split("\n")[0] || "";
|
|
337
|
+
return first.length > max ? first.substring(0, max) + "…" : first;
|
|
338
|
+
}
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* PermissionPrompt — asks user to allow/deny a tool action
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import { TextAttributes } from "@opentui/core";
|
|
6
|
+
import { useState } from "react";
|
|
7
|
+
import { useKeyboard } from "@opentui/react";
|
|
8
|
+
import { useTheme } from "../context/theme";
|
|
9
|
+
|
|
10
|
+
export interface PermissionPromptProps {
|
|
11
|
+
toolName: string;
|
|
12
|
+
message: string;
|
|
13
|
+
onDecision: (decision: "allow" | "always" | "deny") => void;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
const OPTIONS = [
|
|
17
|
+
{ key: "1", label: "Allow once", value: "allow" as const },
|
|
18
|
+
{ key: "2", label: "Always allow", value: "always" as const },
|
|
19
|
+
{ key: "3", label: "Deny", value: "deny" as const },
|
|
20
|
+
];
|
|
21
|
+
|
|
22
|
+
export function PermissionPrompt(props: PermissionPromptProps) {
|
|
23
|
+
const { theme } = useTheme();
|
|
24
|
+
const t = theme;
|
|
25
|
+
const [selected, setSelected] = useState(0);
|
|
26
|
+
|
|
27
|
+
useKeyboard((key: any) => {
|
|
28
|
+
if (key.name === "up" || key.name === "k") {
|
|
29
|
+
setSelected((s) => Math.max(0, s - 1));
|
|
30
|
+
} else if (key.name === "down" || key.name === "j") {
|
|
31
|
+
setSelected((s) => Math.min(OPTIONS.length - 1, s + 1));
|
|
32
|
+
} else if (key.name === "return") {
|
|
33
|
+
props.onDecision(OPTIONS[selected].value);
|
|
34
|
+
} else if (key.name === "1") {
|
|
35
|
+
props.onDecision("allow");
|
|
36
|
+
} else if (key.name === "2") {
|
|
37
|
+
props.onDecision("always");
|
|
38
|
+
} else if (key.name === "3") {
|
|
39
|
+
props.onDecision("deny");
|
|
40
|
+
}
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
return (
|
|
44
|
+
<box
|
|
45
|
+
borderStyle="single"
|
|
46
|
+
borderColor={t.warning}
|
|
47
|
+
paddingX={1}
|
|
48
|
+
paddingY={0}
|
|
49
|
+
flexDirection="column"
|
|
50
|
+
>
|
|
51
|
+
<text fg={t.warning} attributes={TextAttributes.BOLD}>
|
|
52
|
+
{"🔐 Permission Required"}
|
|
53
|
+
</text>
|
|
54
|
+
<text fg={t.text}>
|
|
55
|
+
{` ${props.toolName}: ${props.message}`}
|
|
56
|
+
</text>
|
|
57
|
+
<text fg={t.textDim}>{""}</text>
|
|
58
|
+
{OPTIONS.map((opt, i) => (
|
|
59
|
+
<box key={opt.key}>
|
|
60
|
+
<text
|
|
61
|
+
fg={selected === i ? t.primary : t.textMuted}
|
|
62
|
+
attributes={selected === i ? TextAttributes.BOLD : undefined}
|
|
63
|
+
>
|
|
64
|
+
{` ${selected === i ? "❯" : " "} [${opt.key}] ${opt.label}`}
|
|
65
|
+
</text>
|
|
66
|
+
</box>
|
|
67
|
+
))}
|
|
68
|
+
<text fg={t.textDim}>{"\n ↑↓ Navigate Enter Select 1-3 Quick pick"}</text>
|
|
69
|
+
</box>
|
|
70
|
+
);
|
|
71
|
+
}
|
|
@@ -0,0 +1,220 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* SessionBrowser — Interactive TUI overlay for browsing saved conversations
|
|
3
|
+
*
|
|
4
|
+
* Features:
|
|
5
|
+
* - Arrow keys to navigate
|
|
6
|
+
* - Enter to resume a conversation
|
|
7
|
+
* - d to delete, f to fork, v to view
|
|
8
|
+
* - Escape to close
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import { TextAttributes } from "@opentui/core";
|
|
12
|
+
import { useState, useMemo } from "react";
|
|
13
|
+
import { useKeyboard, useTerminalDimensions } from "@opentui/react";
|
|
14
|
+
import { useTheme } from "../context/theme";
|
|
15
|
+
import {
|
|
16
|
+
listConversations,
|
|
17
|
+
deleteConversation,
|
|
18
|
+
forkConversation,
|
|
19
|
+
formatRelativeDate,
|
|
20
|
+
type Conversation,
|
|
21
|
+
} from "../lib/history";
|
|
22
|
+
|
|
23
|
+
export interface SessionBrowserProps {
|
|
24
|
+
onResume: (conv: Conversation) => void;
|
|
25
|
+
onClose: () => void;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export function SessionBrowser(props: SessionBrowserProps) {
|
|
29
|
+
const { theme } = useTheme();
|
|
30
|
+
const t = theme;
|
|
31
|
+
const dims = useTerminalDimensions();
|
|
32
|
+
|
|
33
|
+
const [conversations, setConversations] = useState(() => listConversations());
|
|
34
|
+
const [selected, setSelected] = useState(0);
|
|
35
|
+
const [confirmDelete, setConfirmDelete] = useState(false);
|
|
36
|
+
const [viewMode, setViewMode] = useState(false);
|
|
37
|
+
const [viewScroll, setViewScroll] = useState(0);
|
|
38
|
+
|
|
39
|
+
const maxVisible = Math.max(5, Math.floor((dims.height || 20) - 10));
|
|
40
|
+
|
|
41
|
+
const scrollOffset = useMemo(() => {
|
|
42
|
+
if (selected < maxVisible) return 0;
|
|
43
|
+
return selected - maxVisible + 1;
|
|
44
|
+
}, [selected, maxVisible]);
|
|
45
|
+
|
|
46
|
+
const visibleConvs = conversations.slice(scrollOffset, scrollOffset + maxVisible);
|
|
47
|
+
|
|
48
|
+
useKeyboard((key: any) => {
|
|
49
|
+
if (viewMode) {
|
|
50
|
+
// View mode controls
|
|
51
|
+
if (key.name === "escape" || key.name === "q") {
|
|
52
|
+
setViewMode(false);
|
|
53
|
+
setViewScroll(0);
|
|
54
|
+
} else if (key.name === "up" || key.name === "k") {
|
|
55
|
+
setViewScroll((s) => Math.max(0, s - 1));
|
|
56
|
+
} else if (key.name === "down" || key.name === "j") {
|
|
57
|
+
const conv = conversations[selected];
|
|
58
|
+
if (conv) {
|
|
59
|
+
const msgs = conv.messages.filter((m) => m.role !== "tool");
|
|
60
|
+
setViewScroll((s) => Math.min(msgs.length - 1, s + 1));
|
|
61
|
+
}
|
|
62
|
+
} else if (key.name === "return") {
|
|
63
|
+
const conv = conversations[selected];
|
|
64
|
+
if (conv) props.onResume(conv);
|
|
65
|
+
}
|
|
66
|
+
return;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
if (confirmDelete) {
|
|
70
|
+
if (key.name === "y") {
|
|
71
|
+
const conv = conversations[selected];
|
|
72
|
+
if (conv) {
|
|
73
|
+
deleteConversation(conv.id);
|
|
74
|
+
const updated = listConversations();
|
|
75
|
+
setConversations(updated);
|
|
76
|
+
setSelected((s) => Math.min(s, updated.length - 1));
|
|
77
|
+
}
|
|
78
|
+
setConfirmDelete(false);
|
|
79
|
+
} else {
|
|
80
|
+
setConfirmDelete(false);
|
|
81
|
+
}
|
|
82
|
+
return;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
if (key.name === "escape" || key.name === "q") {
|
|
86
|
+
props.onClose();
|
|
87
|
+
} else if (key.name === "up" || key.name === "k") {
|
|
88
|
+
setSelected((s) => Math.max(0, s - 1));
|
|
89
|
+
} else if (key.name === "down" || key.name === "j") {
|
|
90
|
+
setSelected((s) => Math.min(conversations.length - 1, s + 1));
|
|
91
|
+
} else if (key.name === "return") {
|
|
92
|
+
const conv = conversations[selected];
|
|
93
|
+
if (conv) props.onResume(conv);
|
|
94
|
+
} else if (key.name === "d") {
|
|
95
|
+
if (conversations.length > 0) setConfirmDelete(true);
|
|
96
|
+
} else if (key.name === "f") {
|
|
97
|
+
const conv = conversations[selected];
|
|
98
|
+
if (conv) {
|
|
99
|
+
const forked = forkConversation(conv);
|
|
100
|
+
if (forked) {
|
|
101
|
+
setConversations(listConversations());
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
} else if (key.name === "v") {
|
|
105
|
+
if (conversations.length > 0) {
|
|
106
|
+
setViewMode(true);
|
|
107
|
+
setViewScroll(0);
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
if (conversations.length === 0) {
|
|
113
|
+
return (
|
|
114
|
+
<box
|
|
115
|
+
borderStyle="single"
|
|
116
|
+
borderColor={t.primary}
|
|
117
|
+
paddingX={2}
|
|
118
|
+
paddingY={1}
|
|
119
|
+
flexDirection="column"
|
|
120
|
+
flexGrow={1}
|
|
121
|
+
>
|
|
122
|
+
<text fg={t.primary} attributes={TextAttributes.BOLD}>
|
|
123
|
+
{"Sessions"}
|
|
124
|
+
</text>
|
|
125
|
+
<text fg={t.textDim}>{"\nNo saved conversations.\n"}</text>
|
|
126
|
+
<text fg={t.textMuted}>{"Esc Close"}</text>
|
|
127
|
+
</box>
|
|
128
|
+
);
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
// View mode — show messages from selected conversation
|
|
132
|
+
if (viewMode) {
|
|
133
|
+
const conv = conversations[selected];
|
|
134
|
+
const msgs = conv ? conv.messages.filter((m) => m.role !== "tool") : [];
|
|
135
|
+
const visibleMsgs = msgs.slice(viewScroll, viewScroll + maxVisible);
|
|
136
|
+
const total = msgs.length;
|
|
137
|
+
|
|
138
|
+
return (
|
|
139
|
+
<box
|
|
140
|
+
borderStyle="single"
|
|
141
|
+
borderColor={t.primary}
|
|
142
|
+
paddingX={1}
|
|
143
|
+
paddingY={0}
|
|
144
|
+
flexDirection="column"
|
|
145
|
+
flexGrow={1}
|
|
146
|
+
>
|
|
147
|
+
<text fg={t.primary} attributes={TextAttributes.BOLD}>
|
|
148
|
+
{`Viewing: ${conv?.title || "Untitled"}`}
|
|
149
|
+
</text>
|
|
150
|
+
<text fg={t.textDim}>
|
|
151
|
+
{`${viewScroll + 1}–${Math.min(viewScroll + maxVisible, total)} of ${total} messages`}
|
|
152
|
+
</text>
|
|
153
|
+
<text>{""}</text>
|
|
154
|
+
{visibleMsgs.map((m, i) => {
|
|
155
|
+
const prefix = m.role === "user" ? "❯" : "◆";
|
|
156
|
+
const color = m.role === "user" ? t.success : t.text;
|
|
157
|
+
const content = m.content.length > 120 ? m.content.substring(0, 117) + "..." : m.content;
|
|
158
|
+
return (
|
|
159
|
+
<text key={`view-${viewScroll + i}`} fg={color}>
|
|
160
|
+
{` ${prefix} ${content.replace(/\n/g, " ")}`}
|
|
161
|
+
</text>
|
|
162
|
+
);
|
|
163
|
+
})}
|
|
164
|
+
<text>{""}</text>
|
|
165
|
+
<text fg={t.textMuted}>{" ↑↓ Scroll Enter Resume Esc Back"}</text>
|
|
166
|
+
</box>
|
|
167
|
+
);
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
// List mode
|
|
171
|
+
return (
|
|
172
|
+
<box
|
|
173
|
+
borderStyle="single"
|
|
174
|
+
borderColor={t.primary}
|
|
175
|
+
paddingX={1}
|
|
176
|
+
paddingY={0}
|
|
177
|
+
flexDirection="column"
|
|
178
|
+
flexGrow={1}
|
|
179
|
+
>
|
|
180
|
+
<text fg={t.primary} attributes={TextAttributes.BOLD}>
|
|
181
|
+
{"Sessions"}
|
|
182
|
+
</text>
|
|
183
|
+
<text fg={t.textDim}>
|
|
184
|
+
{`${conversations.length} conversation${conversations.length !== 1 ? "s" : ""}`}
|
|
185
|
+
</text>
|
|
186
|
+
<text>{""}</text>
|
|
187
|
+
{visibleConvs.map((conv, i) => {
|
|
188
|
+
const idx = scrollOffset + i;
|
|
189
|
+
const isSelected = idx === selected;
|
|
190
|
+
const date = formatRelativeDate(conv.updatedAt);
|
|
191
|
+
const msgCount = conv.messages.filter((m) => m.role === "user").length;
|
|
192
|
+
const title = conv.title.length > 40 ? conv.title.substring(0, 37) + "..." : conv.title;
|
|
193
|
+
|
|
194
|
+
return (
|
|
195
|
+
<box key={conv.id} flexDirection="row">
|
|
196
|
+
<text
|
|
197
|
+
fg={isSelected ? t.primary : t.textMuted}
|
|
198
|
+
attributes={isSelected ? TextAttributes.BOLD : undefined}
|
|
199
|
+
>
|
|
200
|
+
{` ${isSelected ? "❯" : " "} ${title}`}
|
|
201
|
+
</text>
|
|
202
|
+
<text fg={t.textDim}>
|
|
203
|
+
{` ${date} (${msgCount} msgs)`}
|
|
204
|
+
</text>
|
|
205
|
+
</box>
|
|
206
|
+
);
|
|
207
|
+
})}
|
|
208
|
+
{confirmDelete && (
|
|
209
|
+
<box>
|
|
210
|
+
<text>{""}</text>
|
|
211
|
+
<text fg={t.warning} attributes={TextAttributes.BOLD}>
|
|
212
|
+
{` Delete "${conversations[selected]?.title}"? (y/n)`}
|
|
213
|
+
</text>
|
|
214
|
+
</box>
|
|
215
|
+
)}
|
|
216
|
+
<text>{""}</text>
|
|
217
|
+
<text fg={t.textMuted}>{" ↑↓ Navigate Enter Resume v View d Delete f Fork Esc Close"}</text>
|
|
218
|
+
</box>
|
|
219
|
+
);
|
|
220
|
+
}
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* SessionFooter — bottom status line with directory and shortcut hints.
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import { useTheme } from "../context/theme";
|
|
6
|
+
|
|
7
|
+
export interface SessionFooterProps {
|
|
8
|
+
workingDir: string;
|
|
9
|
+
isProcessing: boolean;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export function SessionFooter(props: SessionFooterProps) {
|
|
13
|
+
const { theme } = useTheme();
|
|
14
|
+
const t = theme;
|
|
15
|
+
|
|
16
|
+
const home = process.env.HOME || "";
|
|
17
|
+
const shortDir = home && props.workingDir.startsWith(home)
|
|
18
|
+
? "~" + props.workingDir.slice(home.length)
|
|
19
|
+
: props.workingDir;
|
|
20
|
+
|
|
21
|
+
const shortcuts = "^N:New ^P:Model ^T:Theme ^S:Sessions ^B:Sidebar";
|
|
22
|
+
|
|
23
|
+
return (
|
|
24
|
+
<box height={1} flexDirection="row">
|
|
25
|
+
<text fg={t.textDim}>{` ${shortDir}`}</text>
|
|
26
|
+
<box flexGrow={1} />
|
|
27
|
+
<text fg={t.textMuted}>{`${shortcuts} `}</text>
|
|
28
|
+
</box>
|
|
29
|
+
);
|
|
30
|
+
}
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* SessionHeader — top-line session info bar.
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import { TextAttributes } from "@opentui/core";
|
|
6
|
+
import { useTheme } from "../context/theme";
|
|
7
|
+
|
|
8
|
+
export interface SessionHeaderProps {
|
|
9
|
+
title: string;
|
|
10
|
+
provider: string;
|
|
11
|
+
model: string;
|
|
12
|
+
tokens?: { input: number; output: number };
|
|
13
|
+
contextPercent?: number;
|
|
14
|
+
status: string;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export function SessionHeader(props: SessionHeaderProps) {
|
|
18
|
+
const { theme } = useTheme();
|
|
19
|
+
const t = theme;
|
|
20
|
+
|
|
21
|
+
const inTok = props.tokens ? props.tokens.input.toLocaleString() : "0";
|
|
22
|
+
const outTok = props.tokens ? props.tokens.output.toLocaleString() : "0";
|
|
23
|
+
const pct = props.contextPercent ? Math.round(props.contextPercent) : 0;
|
|
24
|
+
const pctColor = pct > 75 ? t.error : pct > 50 ? t.warning : t.success;
|
|
25
|
+
const statusColor = props.status === "Error" ? t.error
|
|
26
|
+
: props.status === "Processing..." ? t.warning : t.success;
|
|
27
|
+
|
|
28
|
+
const left = ` ◆ ${props.title || "Session"} │ ${props.provider}/${props.model} │ ${inTok}→${outTok} tokens`;
|
|
29
|
+
|
|
30
|
+
return (
|
|
31
|
+
<box height={1} flexDirection="row">
|
|
32
|
+
<text fg={t.primary} attributes={TextAttributes.BOLD}>{left}</text>
|
|
33
|
+
<text fg={t.border}>{" │ "}</text>
|
|
34
|
+
<text fg={pctColor}>{`${pct}%`}</text>
|
|
35
|
+
<box flexGrow={1} />
|
|
36
|
+
<text fg={statusColor}>{`${props.status} `}</text>
|
|
37
|
+
</box>
|
|
38
|
+
);
|
|
39
|
+
}
|