@amanm/openpaw 0.1.0
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/AGENTS.md +1 -0
- package/README.md +144 -0
- package/agent/agent.ts +217 -0
- package/agent/context-scan.ts +81 -0
- package/agent/file-editor-store.ts +27 -0
- package/agent/index.ts +31 -0
- package/agent/memory-store.ts +404 -0
- package/agent/model.ts +14 -0
- package/agent/prompt-builder.ts +139 -0
- package/agent/prompt-context-files.ts +151 -0
- package/agent/sandbox-paths.ts +52 -0
- package/agent/session-store.ts +80 -0
- package/agent/skill-catalog.ts +25 -0
- package/agent/skills/discover.ts +100 -0
- package/agent/tool-stream-format.ts +126 -0
- package/agent/tool-yaml-like.ts +96 -0
- package/agent/tools/bash.ts +100 -0
- package/agent/tools/file-editor.ts +293 -0
- package/agent/tools/list-dir.ts +58 -0
- package/agent/tools/load-skill.ts +40 -0
- package/agent/tools/memory.ts +84 -0
- package/agent/turn-context.ts +46 -0
- package/agent/types.ts +37 -0
- package/agent/workspace-bootstrap.ts +98 -0
- package/bin/openpaw.cjs +177 -0
- package/bundled-skills/find-skills/SKILL.md +163 -0
- package/cli/components/chat-app.tsx +759 -0
- package/cli/components/onboard-ui.tsx +325 -0
- package/cli/components/theme.ts +16 -0
- package/cli/configure.tsx +0 -0
- package/cli/lib/chat-transcript-types.ts +11 -0
- package/cli/lib/markdown-render-node.ts +523 -0
- package/cli/lib/onboard-markdown-syntax-style.ts +55 -0
- package/cli/lib/ui-messages-to-chat-transcript.ts +157 -0
- package/cli/lib/use-auto-copy-selection.ts +38 -0
- package/cli/onboard.tsx +248 -0
- package/cli/openpaw.tsx +144 -0
- package/cli/reset.ts +12 -0
- package/cli/tui.tsx +31 -0
- package/config/index.ts +3 -0
- package/config/paths.ts +71 -0
- package/config/personality-copy.ts +68 -0
- package/config/storage.ts +80 -0
- package/config/types.ts +37 -0
- package/gateway/bootstrap.ts +25 -0
- package/gateway/channel-adapter.ts +8 -0
- package/gateway/daemon-manager.ts +191 -0
- package/gateway/index.ts +18 -0
- package/gateway/session-key.ts +13 -0
- package/gateway/slash-command-tokens.ts +39 -0
- package/gateway/start-messaging.ts +40 -0
- package/gateway/telegram/active-thread-store.ts +89 -0
- package/gateway/telegram/adapter.ts +290 -0
- package/gateway/telegram/assistant-markdown.ts +48 -0
- package/gateway/telegram/bot-commands.ts +40 -0
- package/gateway/telegram/chat-preferences.ts +100 -0
- package/gateway/telegram/constants.ts +5 -0
- package/gateway/telegram/index.ts +4 -0
- package/gateway/telegram/message-html.ts +138 -0
- package/gateway/telegram/message-queue.ts +19 -0
- package/gateway/telegram/reserved-command-filter.ts +33 -0
- package/gateway/telegram/session-file-discovery.ts +62 -0
- package/gateway/telegram/session-key.ts +13 -0
- package/gateway/telegram/session-label.ts +14 -0
- package/gateway/telegram/sessions-list-reply.ts +39 -0
- package/gateway/telegram/stream-delivery.ts +618 -0
- package/gateway/tui/constants.ts +2 -0
- package/gateway/tui/tui-active-thread-store.ts +103 -0
- package/gateway/tui/tui-session-discovery.ts +94 -0
- package/gateway/tui/tui-session-label.ts +22 -0
- package/gateway/tui/tui-sessions-list-message.ts +37 -0
- package/package.json +52 -0
|
@@ -0,0 +1,759 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Terminal chat UI: streams assistant replies from AgentRuntime and shows
|
|
3
|
+
* a bordered transcript with onboarding-aligned colors.
|
|
4
|
+
*/
|
|
5
|
+
import { useCallback, useEffect, useMemo, useState } from "react";
|
|
6
|
+
import { useKeyboard, useTerminalDimensions } from "@opentui/react";
|
|
7
|
+
import { TextAttributes, type SyntaxStyle } from "@opentui/core";
|
|
8
|
+
import { useAutoCopySelection } from "../lib/use-auto-copy-selection";
|
|
9
|
+
import type { AgentRuntime } from "../../agent/agent";
|
|
10
|
+
import { loadSessionMessages } from "../../agent/session-store";
|
|
11
|
+
import { formatToolStreamEventForTui } from "../../agent/tool-stream-format";
|
|
12
|
+
import type { SessionId, ToolStreamEvent } from "../../agent/types";
|
|
13
|
+
import {
|
|
14
|
+
firstCommandToken,
|
|
15
|
+
RESERVED_SLASH_COMMANDS,
|
|
16
|
+
restAfterCommand,
|
|
17
|
+
} from "../../gateway/slash-command-tokens";
|
|
18
|
+
import {
|
|
19
|
+
setActiveTuiSession,
|
|
20
|
+
startNewTuiThread,
|
|
21
|
+
} from "../../gateway/tui/tui-active-thread-store";
|
|
22
|
+
import { listTuiSessions } from "../../gateway/tui/tui-session-discovery";
|
|
23
|
+
import { formatTuiSessionLabel } from "../../gateway/tui/tui-session-label";
|
|
24
|
+
import { formatTuiSessionsListMessage } from "../../gateway/tui/tui-sessions-list-message";
|
|
25
|
+
import type { AssistantSegment, ChatLine } from "../lib/chat-transcript-types";
|
|
26
|
+
import { createOpenpawMarkdownRenderNode } from "../lib/markdown-render-node";
|
|
27
|
+
import { createOnboardMarkdownSyntaxStyle } from "../lib/onboard-markdown-syntax-style";
|
|
28
|
+
import { uiMessagesToChatLines } from "../lib/ui-messages-to-chat-transcript";
|
|
29
|
+
import { ONBOARD } from "./theme";
|
|
30
|
+
|
|
31
|
+
export type { AssistantSegment, ChatLine } from "../lib/chat-transcript-types";
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Appends a stream delta to assistant segments, merging with the trailing segment when kinds match.
|
|
35
|
+
*/
|
|
36
|
+
function appendAssistantSegment(
|
|
37
|
+
segments: AssistantSegment[],
|
|
38
|
+
kind: AssistantSegment["kind"],
|
|
39
|
+
delta: string,
|
|
40
|
+
): AssistantSegment[] {
|
|
41
|
+
const last = segments[segments.length - 1];
|
|
42
|
+
if (last?.kind === kind) {
|
|
43
|
+
return [...segments.slice(0, -1), { kind, text: last.text + delta }];
|
|
44
|
+
}
|
|
45
|
+
return [...segments, { kind, text: delta }];
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* True when an in-progress assistant row has no visible characters yet.
|
|
50
|
+
*/
|
|
51
|
+
function assistantLineIsEmpty(line: Extract<ChatLine, { role: "assistant" }>): boolean {
|
|
52
|
+
return line.segments.length === 0 || line.segments.every((s) => s.text.length === 0);
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
const SLASH_SUGGESTIONS: { command: string; description: string }[] = [
|
|
56
|
+
{ command: "/new", description: "Start a new conversation thread" },
|
|
57
|
+
{ command: "/sessions", description: "List saved sessions" },
|
|
58
|
+
{ command: "/resume", description: "Resume session by number (see /sessions)" },
|
|
59
|
+
{ command: "/sandbox", description: "on or off — workspace filesystem & shell scope" },
|
|
60
|
+
];
|
|
61
|
+
|
|
62
|
+
function assistantHasVisibleText(line: Extract<ChatLine, { role: "assistant" }>): boolean {
|
|
63
|
+
return line.segments.some((s) => s.kind === "text" && s.text.length > 0);
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* True when a completed assistant turn has no visible text, reasoning, or tool output.
|
|
68
|
+
*/
|
|
69
|
+
function assistantSegmentsAreBlank(segments: AssistantSegment[]): boolean {
|
|
70
|
+
return !segments.some((s) => s.text.trim().length > 0);
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
function ChatMessageBlock({
|
|
74
|
+
line,
|
|
75
|
+
lineIndex,
|
|
76
|
+
linesLength,
|
|
77
|
+
busy,
|
|
78
|
+
markdownWidth,
|
|
79
|
+
syntaxStyle,
|
|
80
|
+
markdownRenderNode,
|
|
81
|
+
}: {
|
|
82
|
+
line: ChatLine;
|
|
83
|
+
lineIndex: number;
|
|
84
|
+
linesLength: number;
|
|
85
|
+
busy: boolean;
|
|
86
|
+
markdownWidth: number;
|
|
87
|
+
syntaxStyle: SyntaxStyle;
|
|
88
|
+
markdownRenderNode: ReturnType<typeof createOpenpawMarkdownRenderNode>;
|
|
89
|
+
}) {
|
|
90
|
+
const isLastLine = lineIndex === linesLength - 1;
|
|
91
|
+
const isStreamingAssistant = busy && isLastLine && line.role === "assistant";
|
|
92
|
+
|
|
93
|
+
if (line.role === "user") {
|
|
94
|
+
return (
|
|
95
|
+
<box flexDirection="column" gap={0} marginBottom={1}>
|
|
96
|
+
<text fg={ONBOARD.roleLabel} attributes={TextAttributes.BOLD} selectable>
|
|
97
|
+
<strong>You</strong>
|
|
98
|
+
</text>
|
|
99
|
+
<box flexDirection="column" paddingTop={1}>
|
|
100
|
+
<text fg={ONBOARD.text} selectable>
|
|
101
|
+
{line.text}
|
|
102
|
+
</text>
|
|
103
|
+
</box>
|
|
104
|
+
</box>
|
|
105
|
+
);
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
if (line.role === "assistant") {
|
|
109
|
+
const nonEmpty = line.segments.filter((s) => s.text.length > 0);
|
|
110
|
+
let lastTextNonEmptyIndex = -1;
|
|
111
|
+
for (let i = nonEmpty.length - 1; i >= 0; i--) {
|
|
112
|
+
if (nonEmpty[i]!.kind === "text") {
|
|
113
|
+
lastTextNonEmptyIndex = i;
|
|
114
|
+
break;
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
const showSpinnerForMissingText =
|
|
118
|
+
isStreamingAssistant && !assistantHasVisibleText(line) && nonEmpty.length > 0;
|
|
119
|
+
|
|
120
|
+
return (
|
|
121
|
+
<box flexDirection="column" gap={0} marginBottom={1}>
|
|
122
|
+
<text fg={ONBOARD.roleLabel} attributes={TextAttributes.BOLD} selectable>
|
|
123
|
+
<strong>Assistant</strong>
|
|
124
|
+
</text>
|
|
125
|
+
{nonEmpty.length === 0 ? (
|
|
126
|
+
isStreamingAssistant ? (
|
|
127
|
+
<BusySpinner />
|
|
128
|
+
) : (
|
|
129
|
+
<text fg={ONBOARD.muted} selectable>
|
|
130
|
+
No reply
|
|
131
|
+
</text>
|
|
132
|
+
)
|
|
133
|
+
) : (
|
|
134
|
+
<box flexDirection="column" gap={1} width="100%">
|
|
135
|
+
{nonEmpty.map((s, i) =>
|
|
136
|
+
s.kind === "reasoning" ? (
|
|
137
|
+
<box key={i} flexDirection="column" paddingTop={1} paddingBottom={0}>
|
|
138
|
+
<text fg={ONBOARD.hint} selectable>
|
|
139
|
+
{s.text}
|
|
140
|
+
</text>
|
|
141
|
+
</box>
|
|
142
|
+
) : s.kind === "tool" ? (
|
|
143
|
+
<box key={i} flexDirection="column" padding={0} gap={0}>
|
|
144
|
+
<markdown
|
|
145
|
+
content={s.text}
|
|
146
|
+
syntaxStyle={syntaxStyle}
|
|
147
|
+
width={markdownWidth}
|
|
148
|
+
streaming={false}
|
|
149
|
+
conceal={false}
|
|
150
|
+
renderNode={markdownRenderNode}
|
|
151
|
+
tableOptions={{
|
|
152
|
+
widthMode: "content",
|
|
153
|
+
borderStyle: "single",
|
|
154
|
+
borderColor: ONBOARD.hint,
|
|
155
|
+
cellPadding: 0,
|
|
156
|
+
selectable: true,
|
|
157
|
+
}}
|
|
158
|
+
/>
|
|
159
|
+
</box>
|
|
160
|
+
) : (
|
|
161
|
+
<markdown
|
|
162
|
+
key={i}
|
|
163
|
+
content={s.text}
|
|
164
|
+
syntaxStyle={syntaxStyle}
|
|
165
|
+
width={markdownWidth}
|
|
166
|
+
streaming={isStreamingAssistant && i === lastTextNonEmptyIndex}
|
|
167
|
+
conceal
|
|
168
|
+
renderNode={markdownRenderNode}
|
|
169
|
+
tableOptions={{
|
|
170
|
+
widthMode: "content",
|
|
171
|
+
borderStyle: "single",
|
|
172
|
+
borderColor: ONBOARD.hint,
|
|
173
|
+
cellPadding: 0,
|
|
174
|
+
selectable: true,
|
|
175
|
+
}}
|
|
176
|
+
/>
|
|
177
|
+
),
|
|
178
|
+
)}
|
|
179
|
+
{showSpinnerForMissingText ? (
|
|
180
|
+
<box paddingTop={1}>
|
|
181
|
+
<BusySpinner />
|
|
182
|
+
</box>
|
|
183
|
+
) : null}
|
|
184
|
+
</box>
|
|
185
|
+
)}
|
|
186
|
+
</box>
|
|
187
|
+
);
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
const isError = line.text.startsWith("Error:");
|
|
191
|
+
return (
|
|
192
|
+
<box flexDirection="column" gap={0} marginBottom={1}>
|
|
193
|
+
<text fg={isError ? ONBOARD.error : ONBOARD.hint} selectable>
|
|
194
|
+
{line.text}
|
|
195
|
+
</text>
|
|
196
|
+
</box>
|
|
197
|
+
);
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
const defaultWelcomeLines: ChatLine[] = [
|
|
201
|
+
{
|
|
202
|
+
role: "system",
|
|
203
|
+
text:
|
|
204
|
+
"Session ready. Ask anything below. Commands: /new, /sessions, /resume N, /sandbox on|off",
|
|
205
|
+
},
|
|
206
|
+
];
|
|
207
|
+
|
|
208
|
+
/**
|
|
209
|
+
* True when the message is a slash command at the start (leading spaces allowed),
|
|
210
|
+
* not a `/path` in the middle of a sentence.
|
|
211
|
+
*/
|
|
212
|
+
function isSlashCommandLine(draft: string): boolean {
|
|
213
|
+
return draft.trimStart().startsWith("/");
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
/** Minimum width passed to the markdown renderable so wrapping stays stable in tiny terminals. */
|
|
217
|
+
const MIN_MARKDOWN_WIDTH = 20;
|
|
218
|
+
|
|
219
|
+
/** Braille animation frames for a compact loading indicator (OpenTUI dev skill pattern). */
|
|
220
|
+
const SPINNER_FRAMES = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"] as const;
|
|
221
|
+
|
|
222
|
+
/**
|
|
223
|
+
* Shows a small spinner while the assistant is generating but no visible reply text exists yet.
|
|
224
|
+
*/
|
|
225
|
+
function BusySpinner() {
|
|
226
|
+
const [frame, setFrame] = useState(0);
|
|
227
|
+
useEffect(() => {
|
|
228
|
+
const id = setInterval(() => setFrame((f) => (f + 1) % SPINNER_FRAMES.length), 80);
|
|
229
|
+
return () => clearInterval(id);
|
|
230
|
+
}, []);
|
|
231
|
+
return (
|
|
232
|
+
<text fg={ONBOARD.accent} marginTop={1} selectable>
|
|
233
|
+
{SPINNER_FRAMES[frame]} Thinking…
|
|
234
|
+
</text>
|
|
235
|
+
);
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
/**
|
|
239
|
+
* Horizontal space for markdown wrapping: root padding, transcript border, and inner padding.
|
|
240
|
+
*/
|
|
241
|
+
function transcriptMarkdownWidth(terminalWidth: number): number {
|
|
242
|
+
return Math.max(MIN_MARKDOWN_WIDTH, terminalWidth - 6);
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
/**
|
|
246
|
+
* Returns slash commands whose name prefix matches the first segment after `/`.
|
|
247
|
+
*/
|
|
248
|
+
function matchingSlashSuggestions(draft: string): { command: string; description: string }[] {
|
|
249
|
+
if (!isSlashCommandLine(draft)) {
|
|
250
|
+
return [];
|
|
251
|
+
}
|
|
252
|
+
const lead = draft.trimStart();
|
|
253
|
+
const firstSeg = lead.split(/\s+/)[0] ?? "";
|
|
254
|
+
const namePrefix = firstSeg.slice(1).toLowerCase();
|
|
255
|
+
return SLASH_SUGGESTIONS.filter((s) => {
|
|
256
|
+
const name = s.command.slice(1).toLowerCase();
|
|
257
|
+
return namePrefix === "" || name.startsWith(namePrefix);
|
|
258
|
+
});
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
/**
|
|
262
|
+
* Builds the text to send to handlers from draft + chosen command (keeps args after first token).
|
|
263
|
+
*/
|
|
264
|
+
function applyChosenSlashCommand(
|
|
265
|
+
draft: string,
|
|
266
|
+
chosen: { command: string },
|
|
267
|
+
): string {
|
|
268
|
+
const lead = draft.trimStart();
|
|
269
|
+
const parts = lead.split(/\s+/).filter(Boolean);
|
|
270
|
+
const tail = parts.slice(1).join(" ").trim();
|
|
271
|
+
return tail.length > 0 ? `${chosen.command} ${tail}` : chosen.command;
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
/**
|
|
275
|
+
* Root chat view: one session, streaming deltas into the last assistant message.
|
|
276
|
+
*/
|
|
277
|
+
export function ChatApp({
|
|
278
|
+
runtime,
|
|
279
|
+
initialLines = [],
|
|
280
|
+
initialSessionId,
|
|
281
|
+
}: {
|
|
282
|
+
runtime: AgentRuntime;
|
|
283
|
+
/** Prior transcript for the active TUI session when non-empty; otherwise welcome lines are shown. */
|
|
284
|
+
initialLines?: ChatLine[];
|
|
285
|
+
/** Active persistence session id from {@link getTuiPersistenceSessionId}. */
|
|
286
|
+
initialSessionId: SessionId;
|
|
287
|
+
}) {
|
|
288
|
+
const [lines, setLines] = useState<ChatLine[]>(() =>
|
|
289
|
+
initialLines.length > 0 ? initialLines : defaultWelcomeLines,
|
|
290
|
+
);
|
|
291
|
+
const [sessionId, setSessionId] = useState<SessionId>(initialSessionId);
|
|
292
|
+
const [draft, setDraft] = useState("");
|
|
293
|
+
const [busy, setBusy] = useState(false);
|
|
294
|
+
const [suggestionIndex, setSuggestionIndex] = useState(0);
|
|
295
|
+
/** When true, file_editor and bash are workspace-scoped for each agent turn (default). */
|
|
296
|
+
const [sandboxRestricted, setSandboxRestricted] = useState(true);
|
|
297
|
+
|
|
298
|
+
const { width: terminalWidth } = useTerminalDimensions();
|
|
299
|
+
const markdownWidth = transcriptMarkdownWidth(terminalWidth);
|
|
300
|
+
const markdownSyntaxStyle = useMemo(() => createOnboardMarkdownSyntaxStyle(), []);
|
|
301
|
+
const markdownPalette = useMemo(
|
|
302
|
+
() =>
|
|
303
|
+
({
|
|
304
|
+
accent: ONBOARD.accent,
|
|
305
|
+
text: ONBOARD.text,
|
|
306
|
+
muted: ONBOARD.muted,
|
|
307
|
+
hint: ONBOARD.hint,
|
|
308
|
+
success: ONBOARD.success,
|
|
309
|
+
code: "#89ddff",
|
|
310
|
+
linkUrl: "#7dcfff",
|
|
311
|
+
}) as const,
|
|
312
|
+
[],
|
|
313
|
+
);
|
|
314
|
+
const markdownRenderNode = useMemo(
|
|
315
|
+
() => createOpenpawMarkdownRenderNode(markdownPalette),
|
|
316
|
+
[markdownPalette],
|
|
317
|
+
);
|
|
318
|
+
|
|
319
|
+
useAutoCopySelection();
|
|
320
|
+
|
|
321
|
+
const suggestions = useMemo(
|
|
322
|
+
() => (!busy ? matchingSlashSuggestions(draft) : []),
|
|
323
|
+
[draft, busy],
|
|
324
|
+
);
|
|
325
|
+
|
|
326
|
+
useEffect(() => {
|
|
327
|
+
setSuggestionIndex(0);
|
|
328
|
+
}, [draft]);
|
|
329
|
+
|
|
330
|
+
const safeSuggestionIndex = Math.min(
|
|
331
|
+
suggestionIndex,
|
|
332
|
+
Math.max(0, suggestions.length - 1),
|
|
333
|
+
);
|
|
334
|
+
|
|
335
|
+
const applyTranscriptFromDisk = useCallback(
|
|
336
|
+
async (sid: SessionId) => {
|
|
337
|
+
const messages = await loadSessionMessages(sid, runtime.agent.tools);
|
|
338
|
+
const next = uiMessagesToChatLines(messages);
|
|
339
|
+
setLines(next.length > 0 ? next : defaultWelcomeLines);
|
|
340
|
+
},
|
|
341
|
+
[runtime.agent.tools],
|
|
342
|
+
);
|
|
343
|
+
|
|
344
|
+
const handleReservedSlashCommand = useCallback(
|
|
345
|
+
async (text: string): Promise<boolean> => {
|
|
346
|
+
const token = firstCommandToken(text);
|
|
347
|
+
if (!token || !RESERVED_SLASH_COMMANDS.has(token)) {
|
|
348
|
+
return false;
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
if (token === "/new") {
|
|
352
|
+
try {
|
|
353
|
+
const newId = await startNewTuiThread();
|
|
354
|
+
setSessionId(newId);
|
|
355
|
+
await applyTranscriptFromDisk(newId);
|
|
356
|
+
setLines((prev) => [
|
|
357
|
+
...prev,
|
|
358
|
+
{ role: "system", text: "Started a new conversation." },
|
|
359
|
+
]);
|
|
360
|
+
} catch (e) {
|
|
361
|
+
const msg = e instanceof Error ? e.message : String(e);
|
|
362
|
+
setLines((prev) => [...prev, { role: "system", text: `Error: ${msg}` }]);
|
|
363
|
+
}
|
|
364
|
+
return true;
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
if (token === "/sessions") {
|
|
368
|
+
try {
|
|
369
|
+
const entries = await listTuiSessions();
|
|
370
|
+
const body = formatTuiSessionsListMessage(entries, sessionId);
|
|
371
|
+
setLines((prev) => [...prev, { role: "system", text: body }]);
|
|
372
|
+
} catch (e) {
|
|
373
|
+
const msg = e instanceof Error ? e.message : String(e);
|
|
374
|
+
setLines((prev) => [...prev, { role: "system", text: `Error: ${msg}` }]);
|
|
375
|
+
}
|
|
376
|
+
return true;
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
if (token === "/reasoning" || token === "/tool_calls") {
|
|
380
|
+
setLines((prev) => [
|
|
381
|
+
...prev,
|
|
382
|
+
{
|
|
383
|
+
role: "system",
|
|
384
|
+
text: `${token} is only available in the Telegram channel (same bot). Terminal chat always shows the full stream.`,
|
|
385
|
+
},
|
|
386
|
+
]);
|
|
387
|
+
return true;
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
if (token === "/sandbox") {
|
|
391
|
+
const arg = restAfterCommand(text).trim().toLowerCase();
|
|
392
|
+
if (arg !== "on" && arg !== "off") {
|
|
393
|
+
setLines((prev) => [
|
|
394
|
+
...prev,
|
|
395
|
+
{ role: "system", text: "Usage: /sandbox on — or — /sandbox off" },
|
|
396
|
+
]);
|
|
397
|
+
return true;
|
|
398
|
+
}
|
|
399
|
+
const restricted = arg === "on";
|
|
400
|
+
setSandboxRestricted(restricted);
|
|
401
|
+
if (restricted) {
|
|
402
|
+
setLines((prev) => [
|
|
403
|
+
...prev,
|
|
404
|
+
{
|
|
405
|
+
role: "system",
|
|
406
|
+
text: "Filesystem sandbox is on: file_editor and bash are limited to the workspace.",
|
|
407
|
+
},
|
|
408
|
+
]);
|
|
409
|
+
} else {
|
|
410
|
+
setLines((prev) => [
|
|
411
|
+
...prev,
|
|
412
|
+
{
|
|
413
|
+
role: "system",
|
|
414
|
+
text:
|
|
415
|
+
"Filesystem sandbox is off. The agent can read/write outside the workspace and run shell commands with cwd in your home directory. Use only if you trust this session.",
|
|
416
|
+
},
|
|
417
|
+
]);
|
|
418
|
+
}
|
|
419
|
+
return true;
|
|
420
|
+
}
|
|
421
|
+
|
|
422
|
+
if (token === "/resume") {
|
|
423
|
+
const arg = restAfterCommand(text);
|
|
424
|
+
if (!/^\d+$/.test(arg)) {
|
|
425
|
+
setLines((prev) => [
|
|
426
|
+
...prev,
|
|
427
|
+
{
|
|
428
|
+
role: "system",
|
|
429
|
+
text: "Usage: /resume 1 — use /sessions to see numbers.",
|
|
430
|
+
},
|
|
431
|
+
]);
|
|
432
|
+
return true;
|
|
433
|
+
}
|
|
434
|
+
const n = Number.parseInt(arg, 10);
|
|
435
|
+
if (n < 1) {
|
|
436
|
+
setLines((prev) => [
|
|
437
|
+
...prev,
|
|
438
|
+
{
|
|
439
|
+
role: "system",
|
|
440
|
+
text: "Usage: /resume 1 — use /sessions to see numbers.",
|
|
441
|
+
},
|
|
442
|
+
]);
|
|
443
|
+
return true;
|
|
444
|
+
}
|
|
445
|
+
try {
|
|
446
|
+
const entries = await listTuiSessions();
|
|
447
|
+
if (entries.length === 0) {
|
|
448
|
+
setLines((prev) => [...prev, { role: "system", text: "No saved sessions yet." }]);
|
|
449
|
+
return true;
|
|
450
|
+
}
|
|
451
|
+
if (n > entries.length) {
|
|
452
|
+
setLines((prev) => [
|
|
453
|
+
...prev,
|
|
454
|
+
{
|
|
455
|
+
role: "system",
|
|
456
|
+
text: `No session ${n}. Run /sessions (1–${entries.length}).`,
|
|
457
|
+
},
|
|
458
|
+
]);
|
|
459
|
+
return true;
|
|
460
|
+
}
|
|
461
|
+
const chosen = entries[n - 1]!;
|
|
462
|
+
await setActiveTuiSession(chosen.sessionId);
|
|
463
|
+
setSessionId(chosen.sessionId);
|
|
464
|
+
await applyTranscriptFromDisk(chosen.sessionId);
|
|
465
|
+
const label = formatTuiSessionLabel(chosen.sessionId);
|
|
466
|
+
setLines((prev) => [
|
|
467
|
+
...prev,
|
|
468
|
+
{ role: "system", text: `Resumed session ${n} (${label}).` },
|
|
469
|
+
]);
|
|
470
|
+
} catch (e) {
|
|
471
|
+
const msg = e instanceof Error ? e.message : String(e);
|
|
472
|
+
setLines((prev) => [...prev, { role: "system", text: `Error: ${msg}` }]);
|
|
473
|
+
}
|
|
474
|
+
return true;
|
|
475
|
+
}
|
|
476
|
+
|
|
477
|
+
return false;
|
|
478
|
+
},
|
|
479
|
+
[applyTranscriptFromDisk, sessionId],
|
|
480
|
+
);
|
|
481
|
+
|
|
482
|
+
useKeyboard((key) => {
|
|
483
|
+
if (busy || suggestions.length === 0) {
|
|
484
|
+
return;
|
|
485
|
+
}
|
|
486
|
+
if (key.name === "up") {
|
|
487
|
+
setSuggestionIndex((i) => (i - 1 + suggestions.length) % suggestions.length);
|
|
488
|
+
return;
|
|
489
|
+
}
|
|
490
|
+
if (key.name === "down") {
|
|
491
|
+
setSuggestionIndex((i) => (i + 1) % suggestions.length);
|
|
492
|
+
return;
|
|
493
|
+
}
|
|
494
|
+
if (key.name !== "tab") {
|
|
495
|
+
return;
|
|
496
|
+
}
|
|
497
|
+
const pick =
|
|
498
|
+
suggestions[Math.min(suggestionIndex, suggestions.length - 1)] ??
|
|
499
|
+
suggestions[0]!;
|
|
500
|
+
let next = applyChosenSlashCommand(draft, pick);
|
|
501
|
+
if (
|
|
502
|
+
(pick.command === "/resume" || pick.command === "/sandbox") &&
|
|
503
|
+
!restAfterCommand(next)
|
|
504
|
+
) {
|
|
505
|
+
next = `${next} `;
|
|
506
|
+
}
|
|
507
|
+
setDraft(next);
|
|
508
|
+
});
|
|
509
|
+
|
|
510
|
+
const sendMessage = useCallback(
|
|
511
|
+
async (raw: string) => {
|
|
512
|
+
const text = raw.trim();
|
|
513
|
+
if (!text || busy) {
|
|
514
|
+
return;
|
|
515
|
+
}
|
|
516
|
+
|
|
517
|
+
if (isSlashCommandLine(raw)) {
|
|
518
|
+
const token = firstCommandToken(text);
|
|
519
|
+
if (token && RESERVED_SLASH_COMMANDS.has(token)) {
|
|
520
|
+
if (await handleReservedSlashCommand(text)) {
|
|
521
|
+
setDraft("");
|
|
522
|
+
return;
|
|
523
|
+
}
|
|
524
|
+
}
|
|
525
|
+
|
|
526
|
+
const matches = matchingSlashSuggestions(raw);
|
|
527
|
+
if (matches.length === 0) {
|
|
528
|
+
const firstSeg = text.trimStart().split(/\s+/)[0] ?? "";
|
|
529
|
+
setLines((prev) => [
|
|
530
|
+
...prev,
|
|
531
|
+
{
|
|
532
|
+
role: "system",
|
|
533
|
+
text: `Unknown command ${firstSeg}. Try /new, /sessions, /resume, /sandbox, /reasoning, or /tool_calls.`,
|
|
534
|
+
},
|
|
535
|
+
]);
|
|
536
|
+
setDraft("");
|
|
537
|
+
return;
|
|
538
|
+
}
|
|
539
|
+
if (matches.length > 1) {
|
|
540
|
+
setLines((prev) => [
|
|
541
|
+
...prev,
|
|
542
|
+
{
|
|
543
|
+
role: "system",
|
|
544
|
+
text:
|
|
545
|
+
"Ambiguous command; type more characters (e.g. /sess) or use ↑/↓ and Tab to pick.",
|
|
546
|
+
},
|
|
547
|
+
]);
|
|
548
|
+
setDraft("");
|
|
549
|
+
return;
|
|
550
|
+
}
|
|
551
|
+
const resolved = applyChosenSlashCommand(raw, matches[0]!);
|
|
552
|
+
if (await handleReservedSlashCommand(resolved)) {
|
|
553
|
+
setDraft("");
|
|
554
|
+
return;
|
|
555
|
+
}
|
|
556
|
+
setDraft("");
|
|
557
|
+
return;
|
|
558
|
+
}
|
|
559
|
+
|
|
560
|
+
setBusy(true);
|
|
561
|
+
setDraft("");
|
|
562
|
+
setLines((prev) => [...prev, { role: "user", text }]);
|
|
563
|
+
|
|
564
|
+
let assistantSegments: AssistantSegment[] = [];
|
|
565
|
+
|
|
566
|
+
setLines((prev) => [...prev, { role: "assistant", segments: [] }]);
|
|
567
|
+
|
|
568
|
+
try {
|
|
569
|
+
await runtime.runTurn({
|
|
570
|
+
sessionId,
|
|
571
|
+
userText: text,
|
|
572
|
+
surface: "cli",
|
|
573
|
+
sandboxRestricted,
|
|
574
|
+
onReasoningDelta: (delta) => {
|
|
575
|
+
assistantSegments = appendAssistantSegment(assistantSegments, "reasoning", delta);
|
|
576
|
+
const snapshot = assistantSegments;
|
|
577
|
+
setLines((prev) => {
|
|
578
|
+
const next = [...prev];
|
|
579
|
+
const last = next[next.length - 1];
|
|
580
|
+
if (last?.role === "assistant") {
|
|
581
|
+
next[next.length - 1] = { role: "assistant", segments: snapshot };
|
|
582
|
+
}
|
|
583
|
+
return next;
|
|
584
|
+
});
|
|
585
|
+
},
|
|
586
|
+
onTextDelta: (delta) => {
|
|
587
|
+
assistantSegments = appendAssistantSegment(assistantSegments, "text", delta);
|
|
588
|
+
const snapshot = assistantSegments;
|
|
589
|
+
setLines((prev) => {
|
|
590
|
+
const next = [...prev];
|
|
591
|
+
const last = next[next.length - 1];
|
|
592
|
+
if (last?.role === "assistant") {
|
|
593
|
+
next[next.length - 1] = { role: "assistant", segments: snapshot };
|
|
594
|
+
}
|
|
595
|
+
return next;
|
|
596
|
+
});
|
|
597
|
+
},
|
|
598
|
+
onToolStatus: (ev: ToolStreamEvent) => {
|
|
599
|
+
const line = formatToolStreamEventForTui(ev);
|
|
600
|
+
if (!line) {
|
|
601
|
+
return;
|
|
602
|
+
}
|
|
603
|
+
const lastSeg = assistantSegments[assistantSegments.length - 1];
|
|
604
|
+
const prefix = lastSeg?.kind === "tool" ? "\n" : "";
|
|
605
|
+
assistantSegments = appendAssistantSegment(
|
|
606
|
+
assistantSegments,
|
|
607
|
+
"tool",
|
|
608
|
+
`${prefix}${line}`,
|
|
609
|
+
);
|
|
610
|
+
const snapshot = assistantSegments;
|
|
611
|
+
setLines((prev) => {
|
|
612
|
+
const next = [...prev];
|
|
613
|
+
const last = next[next.length - 1];
|
|
614
|
+
if (last?.role === "assistant") {
|
|
615
|
+
next[next.length - 1] = { role: "assistant", segments: snapshot };
|
|
616
|
+
}
|
|
617
|
+
return next;
|
|
618
|
+
});
|
|
619
|
+
},
|
|
620
|
+
});
|
|
621
|
+
|
|
622
|
+
if (assistantSegmentsAreBlank(assistantSegments)) {
|
|
623
|
+
setLines((prev) => {
|
|
624
|
+
const next = [...prev];
|
|
625
|
+
const last = next[next.length - 1];
|
|
626
|
+
if (last?.role === "assistant") {
|
|
627
|
+
next.pop();
|
|
628
|
+
}
|
|
629
|
+
next.push({
|
|
630
|
+
role: "system",
|
|
631
|
+
text:
|
|
632
|
+
"Nothing was streamed to the chat this turn. For paths outside the workspace (e.g. Desktop), run `/sandbox off` first.",
|
|
633
|
+
});
|
|
634
|
+
return next;
|
|
635
|
+
});
|
|
636
|
+
}
|
|
637
|
+
} catch (e) {
|
|
638
|
+
const msg = e instanceof Error ? e.message : String(e);
|
|
639
|
+
setLines((prev) => {
|
|
640
|
+
const next = [...prev];
|
|
641
|
+
const last = next[next.length - 1];
|
|
642
|
+
if (last?.role === "assistant" && assistantLineIsEmpty(last)) {
|
|
643
|
+
next.pop();
|
|
644
|
+
}
|
|
645
|
+
return [...next, { role: "system", text: `Error: ${msg}` }];
|
|
646
|
+
});
|
|
647
|
+
} finally {
|
|
648
|
+
setBusy(false);
|
|
649
|
+
}
|
|
650
|
+
},
|
|
651
|
+
[busy, handleReservedSlashCommand, runtime, sandboxRestricted, sessionId],
|
|
652
|
+
);
|
|
653
|
+
|
|
654
|
+
return (
|
|
655
|
+
<box
|
|
656
|
+
flexDirection="column"
|
|
657
|
+
flexGrow={1}
|
|
658
|
+
paddingTop={1}
|
|
659
|
+
paddingX={1}
|
|
660
|
+
paddingBottom={0}
|
|
661
|
+
gap={0}
|
|
662
|
+
>
|
|
663
|
+
<box flexDirection="row" alignItems="flex-end" gap={2} flexShrink={0} marginBottom={1}>
|
|
664
|
+
<ascii-font font="tiny" text="OpenPaw" color={ONBOARD.accent} marginLeft={1} />
|
|
665
|
+
<text fg={ONBOARD.muted}>Terminal chat</text>
|
|
666
|
+
</box>
|
|
667
|
+
|
|
668
|
+
<box
|
|
669
|
+
flexGrow={1}
|
|
670
|
+
flexDirection="column"
|
|
671
|
+
borderStyle="single"
|
|
672
|
+
borderColor={ONBOARD.hint}
|
|
673
|
+
paddingX={1}
|
|
674
|
+
paddingTop={0}
|
|
675
|
+
paddingBottom={0}
|
|
676
|
+
minHeight={4}
|
|
677
|
+
>
|
|
678
|
+
<scrollbox
|
|
679
|
+
flexGrow={1}
|
|
680
|
+
focused={false}
|
|
681
|
+
stickyScroll
|
|
682
|
+
stickyStart="bottom"
|
|
683
|
+
>
|
|
684
|
+
<box flexDirection="column" gap={0}>
|
|
685
|
+
{lines.map((line, i) => (
|
|
686
|
+
<ChatMessageBlock
|
|
687
|
+
key={i}
|
|
688
|
+
line={line}
|
|
689
|
+
lineIndex={i}
|
|
690
|
+
linesLength={lines.length}
|
|
691
|
+
busy={busy}
|
|
692
|
+
markdownWidth={markdownWidth}
|
|
693
|
+
syntaxStyle={markdownSyntaxStyle}
|
|
694
|
+
markdownRenderNode={markdownRenderNode}
|
|
695
|
+
/>
|
|
696
|
+
))}
|
|
697
|
+
</box>
|
|
698
|
+
</scrollbox>
|
|
699
|
+
</box>
|
|
700
|
+
|
|
701
|
+
<box
|
|
702
|
+
flexShrink={0}
|
|
703
|
+
width="100%"
|
|
704
|
+
flexDirection="column"
|
|
705
|
+
borderStyle="single"
|
|
706
|
+
borderColor={ONBOARD.hint}
|
|
707
|
+
paddingX={1}
|
|
708
|
+
paddingY={0}
|
|
709
|
+
gap={0}
|
|
710
|
+
>
|
|
711
|
+
{suggestions.length > 0 && (
|
|
712
|
+
<box
|
|
713
|
+
flexDirection="column"
|
|
714
|
+
flexShrink={0}
|
|
715
|
+
marginBottom={1}
|
|
716
|
+
paddingLeft={1}
|
|
717
|
+
paddingRight={1}
|
|
718
|
+
paddingTop={1}
|
|
719
|
+
paddingBottom={1}
|
|
720
|
+
gap={0}
|
|
721
|
+
backgroundColor="#1e2030"
|
|
722
|
+
>
|
|
723
|
+
{suggestions.map((s, i) => {
|
|
724
|
+
const active = i === safeSuggestionIndex;
|
|
725
|
+
return (
|
|
726
|
+
<box key={s.command} flexDirection="row" gap={0}>
|
|
727
|
+
<text fg={active ? ONBOARD.accent : ONBOARD.muted} selectable>
|
|
728
|
+
<strong>{active ? "› " : " "}</strong>
|
|
729
|
+
<strong>{s.command}</strong>
|
|
730
|
+
</text>
|
|
731
|
+
<text fg={ONBOARD.muted} selectable>{` — ${s.description}`}</text>
|
|
732
|
+
</box>
|
|
733
|
+
);
|
|
734
|
+
})}
|
|
735
|
+
</box>
|
|
736
|
+
)}
|
|
737
|
+
<input
|
|
738
|
+
focused
|
|
739
|
+
value={draft}
|
|
740
|
+
onInput={setDraft}
|
|
741
|
+
onChange={setDraft}
|
|
742
|
+
onSubmit={(payload) => {
|
|
743
|
+
const raw = typeof payload === "string" ? payload : draft;
|
|
744
|
+
void sendMessage(raw);
|
|
745
|
+
}}
|
|
746
|
+
placeholder={busy ? "Waiting for assistant…" : "Message"}
|
|
747
|
+
textColor={ONBOARD.text}
|
|
748
|
+
cursorColor={ONBOARD.accent}
|
|
749
|
+
/>
|
|
750
|
+
</box>
|
|
751
|
+
|
|
752
|
+
<box flexDirection="column" gap={0} flexShrink={0} marginLeft={1}>
|
|
753
|
+
<text fg={ONBOARD.hint}>
|
|
754
|
+
Enter send · Tab complete · ↑/↓ highlight · Ctrl+C quit
|
|
755
|
+
</text>
|
|
756
|
+
</box>
|
|
757
|
+
</box>
|
|
758
|
+
);
|
|
759
|
+
}
|