hoomanjs 1.12.1 → 1.14.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/README.md +4 -0
- package/package.json +4 -1
- package/src/acp/sessions/config-options.ts +27 -0
- package/src/acp/utils/tool-kind.ts +1 -0
- package/src/chat/app.tsx +210 -22
- package/src/chat/components/ChatMessage.tsx +13 -10
- package/src/chat/components/Composer.tsx +10 -4
- package/src/chat/components/PromptInput.tsx +62 -0
- package/src/chat/components/QueuedPrompts.tsx +76 -0
- package/src/chat/components/StatusBar.tsx +3 -1
- package/src/chat/components/TodoPanel.tsx +49 -0
- package/src/chat/components/markdown/BlockRenderer.tsx +207 -0
- package/src/chat/components/markdown/CodeBlock.tsx +100 -0
- package/src/chat/components/markdown/InlineRenderer.tsx +122 -0
- package/src/chat/components/markdown/MarkdownMessage.tsx +35 -0
- package/src/chat/components/markdown/MarkdownTable.tsx +23 -0
- package/src/chat/components/markdown/hooks/useMarkdownTableLayout.ts +242 -0
- package/src/chat/components/markdown/hooks/useMarkdownTokens.ts +46 -0
- package/src/chat/components/markdown/lexer.ts +100 -0
- package/src/chat/components/prompt-input/clipboard-image.ts +119 -0
- package/src/chat/components/prompt-input/hooks/usePromptInputController.ts +4 -0
- package/src/chat/components/prompt-input/input-model.ts +294 -0
- package/src/chat/components/prompt-input/paste.ts +105 -0
- package/src/chat/components/prompt-input/render.ts +83 -0
- package/src/chat/components/prompt-input/usePromptInputController.ts +493 -0
- package/src/chat/components/shared.ts +1 -1
- package/src/configure/app.tsx +17 -0
- package/src/core/agent/index.ts +9 -2
- package/src/core/approvals/allowed-tools.ts +13 -1
- package/src/core/config.ts +8 -0
- package/src/core/mcp/manager.ts +12 -0
- package/src/core/prompts/static/todo.md +35 -0
- package/src/core/prompts/system.ts +3 -0
- package/src/core/tools/filesystem.ts +11 -88
- package/src/core/tools/index.ts +1 -0
- package/src/core/tools/todo.ts +117 -0
- package/src/core/utils/attachments.ts +201 -0
- package/src/core/utils/paths.ts +4 -0
- package/src/daemon/index.ts +34 -2
package/README.md
CHANGED
|
@@ -160,6 +160,7 @@ hooman daemon --channels --yolo
|
|
|
160
160
|
|
|
161
161
|
Runtime tools and prompt sections are controlled from `config.json` under `tools`:
|
|
162
162
|
|
|
163
|
+
- `tools.todo.enabled`
|
|
163
164
|
- `tools.fetch.enabled`
|
|
164
165
|
- `tools.filesystem.enabled`
|
|
165
166
|
- `tools.shell.enabled`
|
|
@@ -234,6 +235,9 @@ This is the shape managed by `hooman configure`:
|
|
|
234
235
|
"params": {}
|
|
235
236
|
},
|
|
236
237
|
"tools": {
|
|
238
|
+
"todo": {
|
|
239
|
+
"enabled": true
|
|
240
|
+
},
|
|
237
241
|
"fetch": {
|
|
238
242
|
"enabled": true
|
|
239
243
|
},
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "hoomanjs",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.14.0",
|
|
4
4
|
"description": "Hackable Bun-powered AI agent toolkit for building local CLI, ACP, MCP, and channel-driven workflows.",
|
|
5
5
|
"author": {
|
|
6
6
|
"name": "Vaibhav Pandey",
|
|
@@ -61,17 +61,20 @@
|
|
|
61
61
|
"@mozilla/readability": "^0.6.0",
|
|
62
62
|
"@strands-agents/sdk": "^1.0.0-rc.3",
|
|
63
63
|
"chromadb": "^3.4.3",
|
|
64
|
+
"cli-highlight": "^2.1.11",
|
|
64
65
|
"cli-spinners": "^3.4.0",
|
|
65
66
|
"commander": "^14.0.3",
|
|
66
67
|
"fastq": "^1.20.1",
|
|
67
68
|
"gray-matter": "^4.0.3",
|
|
68
69
|
"handlebars": "^4.7.9",
|
|
69
70
|
"ink": "^7.0.0",
|
|
71
|
+
"ink-ansi": "^1.0.0",
|
|
70
72
|
"ink-select-input": "^6.2.0",
|
|
71
73
|
"ink-text-input": "^6.0.0",
|
|
72
74
|
"jsdom": "^29.0.2",
|
|
73
75
|
"lodash": "^4.18.1",
|
|
74
76
|
"luxon": "^3.7.2",
|
|
77
|
+
"marked": "^18.0.2",
|
|
75
78
|
"ollama": "^0.6.3",
|
|
76
79
|
"openai": "^6.34.0",
|
|
77
80
|
"react": "^19.2.5",
|
|
@@ -7,11 +7,25 @@ import type { Config } from "../../core/config.ts";
|
|
|
7
7
|
|
|
8
8
|
export const HOOMAN_LTM_CONFIG_ID = "hooman.longTermMemory" as const;
|
|
9
9
|
export const HOOMAN_WIKI_CONFIG_ID = "hooman.wiki" as const;
|
|
10
|
+
export const HOOMAN_TODO_CONFIG_ID = "hooman.todo" as const;
|
|
10
11
|
|
|
11
12
|
export function buildSessionConfigOptions(
|
|
12
13
|
config: Config,
|
|
13
14
|
): SessionConfigOption[] {
|
|
14
15
|
return [
|
|
16
|
+
{
|
|
17
|
+
type: "select",
|
|
18
|
+
id: HOOMAN_TODO_CONFIG_ID,
|
|
19
|
+
name: "Todo tracking",
|
|
20
|
+
description:
|
|
21
|
+
"When enabled, the agent can use update_todos to track multi-step progress in app state.",
|
|
22
|
+
category: "_hooman",
|
|
23
|
+
currentValue: config.tools.todo.enabled ? "on" : "off",
|
|
24
|
+
options: [
|
|
25
|
+
{ value: "on", name: "On" },
|
|
26
|
+
{ value: "off", name: "Off" },
|
|
27
|
+
],
|
|
28
|
+
},
|
|
15
29
|
{
|
|
16
30
|
type: "select",
|
|
17
31
|
id: HOOMAN_LTM_CONFIG_ID,
|
|
@@ -51,6 +65,7 @@ export function applySessionConfigOption(
|
|
|
51
65
|
});
|
|
52
66
|
}
|
|
53
67
|
if (
|
|
68
|
+
params.configId !== HOOMAN_TODO_CONFIG_ID &&
|
|
54
69
|
params.configId !== HOOMAN_LTM_CONFIG_ID &&
|
|
55
70
|
params.configId !== HOOMAN_WIKI_CONFIG_ID
|
|
56
71
|
) {
|
|
@@ -77,6 +92,18 @@ export function applySessionConfigOption(
|
|
|
77
92
|
return;
|
|
78
93
|
}
|
|
79
94
|
|
|
95
|
+
if (params.configId === HOOMAN_TODO_CONFIG_ID) {
|
|
96
|
+
config.update({
|
|
97
|
+
tools: {
|
|
98
|
+
...config.tools,
|
|
99
|
+
todo: {
|
|
100
|
+
enabled: value === "on",
|
|
101
|
+
},
|
|
102
|
+
},
|
|
103
|
+
});
|
|
104
|
+
return;
|
|
105
|
+
}
|
|
106
|
+
|
|
80
107
|
const chroma = config.wiki.chroma;
|
|
81
108
|
config.update({
|
|
82
109
|
tools: {
|
package/src/chat/app.tsx
CHANGED
|
@@ -5,11 +5,15 @@ import React, {
|
|
|
5
5
|
useRef,
|
|
6
6
|
useState,
|
|
7
7
|
} from "react";
|
|
8
|
+
import fastq from "fastq";
|
|
8
9
|
import { Box, useApp, useInput } from "ink";
|
|
9
10
|
import {
|
|
10
11
|
BeforeToolCallEvent,
|
|
12
|
+
Message,
|
|
13
|
+
TextBlock,
|
|
11
14
|
type Agent,
|
|
12
15
|
type AgentStreamEvent,
|
|
16
|
+
type ContentBlock,
|
|
13
17
|
} from "@strands-agents/sdk";
|
|
14
18
|
import type { Manager as McpManager } from "../core/mcp/index.ts";
|
|
15
19
|
import type { Registry } from "../core/skills/index.ts";
|
|
@@ -19,9 +23,14 @@ import {
|
|
|
19
23
|
} from "./approvals.ts";
|
|
20
24
|
import { ApprovalPrompt } from "./components/ApprovalPrompt.tsx";
|
|
21
25
|
import { Composer } from "./components/Composer.tsx";
|
|
26
|
+
import { QueuedPrompts } from "./components/QueuedPrompts.tsx";
|
|
22
27
|
import { StatusBar } from "./components/StatusBar.tsx";
|
|
28
|
+
import { TodoPanel } from "./components/TodoPanel.tsx";
|
|
23
29
|
import { Transcript } from "./components/Transcript.tsx";
|
|
24
30
|
import type { ApprovalRequest, ChatLine } from "./types.ts";
|
|
31
|
+
import { getTodoViewState, type TodoViewState } from "../core/tools/todo.ts";
|
|
32
|
+
import { attachmentPathsToPromptBlocks } from "../core/utils/attachments.ts";
|
|
33
|
+
import type { PromptSubmission } from "./components/prompt-input/hooks/usePromptInputController.ts";
|
|
25
34
|
|
|
26
35
|
type ChatAppProps = {
|
|
27
36
|
agent: Agent;
|
|
@@ -33,7 +42,27 @@ type ChatAppProps = {
|
|
|
33
42
|
onExit: () => void;
|
|
34
43
|
};
|
|
35
44
|
|
|
36
|
-
|
|
45
|
+
type QueuedPrompt = {
|
|
46
|
+
id: string;
|
|
47
|
+
prompt: PromptSubmission;
|
|
48
|
+
};
|
|
49
|
+
|
|
50
|
+
function normalizePromptSubmission(
|
|
51
|
+
value: string | PromptSubmission,
|
|
52
|
+
): PromptSubmission {
|
|
53
|
+
if (typeof value === "string") {
|
|
54
|
+
return { text: value, attachments: [] };
|
|
55
|
+
}
|
|
56
|
+
return {
|
|
57
|
+
text: value.text,
|
|
58
|
+
attachments: [
|
|
59
|
+
...new Set(value.attachments.map((item) => item.trim()).filter(Boolean)),
|
|
60
|
+
],
|
|
61
|
+
};
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
const INPUT_HINT =
|
|
65
|
+
"enter: queue prompt | shift/meta+enter or \\+enter: newline | esc/ctrl+c: cancel or exit";
|
|
37
66
|
|
|
38
67
|
function nowId(): string {
|
|
39
68
|
return `${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
|
|
@@ -65,6 +94,23 @@ function toToolResultText(result: unknown): string {
|
|
|
65
94
|
return body || `Tool finished with status: ${data.status ?? "unknown"}`;
|
|
66
95
|
}
|
|
67
96
|
|
|
97
|
+
function getToolUseId(value: unknown): string | null {
|
|
98
|
+
if (!value || typeof value !== "object") {
|
|
99
|
+
return null;
|
|
100
|
+
}
|
|
101
|
+
const data = value as {
|
|
102
|
+
toolUseId?: unknown;
|
|
103
|
+
toolUse?: { toolUseId?: unknown };
|
|
104
|
+
};
|
|
105
|
+
if (typeof data.toolUseId === "string") {
|
|
106
|
+
return data.toolUseId;
|
|
107
|
+
}
|
|
108
|
+
if (typeof data.toolUse?.toolUseId === "string") {
|
|
109
|
+
return data.toolUse.toolUseId;
|
|
110
|
+
}
|
|
111
|
+
return null;
|
|
112
|
+
}
|
|
113
|
+
|
|
68
114
|
export function ChatApp({
|
|
69
115
|
agent,
|
|
70
116
|
sessionId,
|
|
@@ -93,11 +139,17 @@ export function ChatApp({
|
|
|
93
139
|
});
|
|
94
140
|
const [pendingApproval, setPendingApproval] =
|
|
95
141
|
useState<ApprovalRequest | null>(null);
|
|
142
|
+
const [queuedPrompts, setQueuedPrompts] = useState<QueuedPrompt[]>([]);
|
|
96
143
|
const [liveReasoning, setLiveReasoning] = useState("");
|
|
144
|
+
const [todoState, setTodoState] = useState<TodoViewState>(() =>
|
|
145
|
+
getTodoViewState(agent),
|
|
146
|
+
);
|
|
97
147
|
const controllerRef = useRef(new ChatApprovalController());
|
|
148
|
+
const mountedRef = useRef(true);
|
|
98
149
|
const runningRef = useRef(false);
|
|
99
150
|
const assistantLineIdRef = useRef<string | null>(null);
|
|
100
|
-
const
|
|
151
|
+
const toolLineIdsRef = useRef(new Map<string, string>());
|
|
152
|
+
const pendingToolLineIdsRef = useRef<string[]>([]);
|
|
101
153
|
const initialRanRef = useRef(false);
|
|
102
154
|
|
|
103
155
|
useEffect(() => {
|
|
@@ -148,6 +200,22 @@ export function ChatApp({
|
|
|
148
200
|
};
|
|
149
201
|
}, [agent, yolo]);
|
|
150
202
|
|
|
203
|
+
useEffect(() => {
|
|
204
|
+
let active = true;
|
|
205
|
+
const refresh = () => {
|
|
206
|
+
if (!active) {
|
|
207
|
+
return;
|
|
208
|
+
}
|
|
209
|
+
setTodoState(getTodoViewState(agent));
|
|
210
|
+
};
|
|
211
|
+
refresh();
|
|
212
|
+
const timer = setInterval(refresh, running ? 200 : 800);
|
|
213
|
+
return () => {
|
|
214
|
+
active = false;
|
|
215
|
+
clearInterval(timer);
|
|
216
|
+
};
|
|
217
|
+
}, [agent, running]);
|
|
218
|
+
|
|
151
219
|
const appendLine = useCallback((line: ChatLine) => {
|
|
152
220
|
setLines((prev) => [...prev, line]);
|
|
153
221
|
}, []);
|
|
@@ -187,11 +255,14 @@ export function ChatApp({
|
|
|
187
255
|
}, []);
|
|
188
256
|
|
|
189
257
|
const runTurn = useCallback(
|
|
190
|
-
async (prompt:
|
|
191
|
-
const trimmed = prompt.trim();
|
|
192
|
-
if (!trimmed
|
|
258
|
+
async (prompt: PromptSubmission) => {
|
|
259
|
+
const trimmed = prompt.text.trim();
|
|
260
|
+
if (!trimmed && prompt.attachments.length === 0) {
|
|
193
261
|
return;
|
|
194
262
|
}
|
|
263
|
+
const attachmentBlocks = await attachmentPathsToPromptBlocks(
|
|
264
|
+
prompt.attachments,
|
|
265
|
+
);
|
|
195
266
|
|
|
196
267
|
runningRef.current = true;
|
|
197
268
|
setRunning(true);
|
|
@@ -202,7 +273,12 @@ export function ChatApp({
|
|
|
202
273
|
appendLine({
|
|
203
274
|
id: nowId(),
|
|
204
275
|
role: "user",
|
|
205
|
-
content:
|
|
276
|
+
content:
|
|
277
|
+
prompt.attachments.length > 0
|
|
278
|
+
? `${trimmed || "[attachments]"}\n\n${prompt.attachments
|
|
279
|
+
.map((attachmentPath) => `[attachment] ${attachmentPath}`)
|
|
280
|
+
.join("\n")}`
|
|
281
|
+
: trimmed,
|
|
206
282
|
done: true,
|
|
207
283
|
});
|
|
208
284
|
|
|
@@ -216,7 +292,19 @@ export function ChatApp({
|
|
|
216
292
|
});
|
|
217
293
|
|
|
218
294
|
try {
|
|
219
|
-
|
|
295
|
+
const streamInput =
|
|
296
|
+
attachmentBlocks.length > 0
|
|
297
|
+
? [
|
|
298
|
+
new Message({
|
|
299
|
+
role: "user",
|
|
300
|
+
content: [
|
|
301
|
+
...(trimmed ? [new TextBlock(trimmed)] : []),
|
|
302
|
+
...attachmentBlocks,
|
|
303
|
+
] as ContentBlock[],
|
|
304
|
+
}),
|
|
305
|
+
]
|
|
306
|
+
: trimmed;
|
|
307
|
+
for await (const event of agent.stream(streamInput)) {
|
|
220
308
|
const e = event as AgentStreamEvent;
|
|
221
309
|
switch (e.type) {
|
|
222
310
|
case "contentBlockEvent": {
|
|
@@ -230,7 +318,12 @@ export function ChatApp({
|
|
|
230
318
|
appendAssistantText(block.text ?? "");
|
|
231
319
|
} else if (block.type === "toolUseBlock") {
|
|
232
320
|
const toolId = nowId();
|
|
233
|
-
|
|
321
|
+
const toolUseId = getToolUseId(block);
|
|
322
|
+
if (toolUseId) {
|
|
323
|
+
toolLineIdsRef.current.set(toolUseId, toolId);
|
|
324
|
+
} else {
|
|
325
|
+
pendingToolLineIdsRef.current.push(toolId);
|
|
326
|
+
}
|
|
234
327
|
appendLine({
|
|
235
328
|
id: toolId,
|
|
236
329
|
role: "tool",
|
|
@@ -248,13 +341,31 @@ export function ChatApp({
|
|
|
248
341
|
}
|
|
249
342
|
case "toolResultEvent": {
|
|
250
343
|
const resultContent = toToolResultText(e.result);
|
|
251
|
-
|
|
252
|
-
|
|
344
|
+
const toolUseId = getToolUseId(e.result);
|
|
345
|
+
let toolLineId = toolUseId
|
|
346
|
+
? toolLineIdsRef.current.get(toolUseId)
|
|
347
|
+
: undefined;
|
|
348
|
+
if (toolLineId && toolUseId) {
|
|
349
|
+
toolLineIdsRef.current.delete(toolUseId);
|
|
350
|
+
}
|
|
351
|
+
toolLineId ??= pendingToolLineIdsRef.current.shift();
|
|
352
|
+
if (!toolLineId) {
|
|
353
|
+
const firstTrackedTool = toolLineIdsRef.current
|
|
354
|
+
.entries()
|
|
355
|
+
.next();
|
|
356
|
+
if (!firstTrackedTool.done) {
|
|
357
|
+
const [trackedToolUseId, trackedToolLineId] =
|
|
358
|
+
firstTrackedTool.value;
|
|
359
|
+
toolLineIdsRef.current.delete(trackedToolUseId);
|
|
360
|
+
toolLineId = trackedToolLineId;
|
|
361
|
+
}
|
|
362
|
+
}
|
|
363
|
+
if (toolLineId) {
|
|
364
|
+
updateLine(toolLineId, {
|
|
253
365
|
phase: "done",
|
|
254
366
|
done: true,
|
|
255
367
|
resultContent,
|
|
256
368
|
});
|
|
257
|
-
toolLineIdRef.current = null;
|
|
258
369
|
} else {
|
|
259
370
|
appendLine({
|
|
260
371
|
id: nowId(),
|
|
@@ -324,10 +435,14 @@ export function ChatApp({
|
|
|
324
435
|
} finally {
|
|
325
436
|
updateLine(assistantId, { done: true });
|
|
326
437
|
assistantLineIdRef.current = null;
|
|
327
|
-
|
|
328
|
-
updateLine(
|
|
329
|
-
toolLineIdRef.current = null;
|
|
438
|
+
for (const toolLineId of toolLineIdsRef.current.values()) {
|
|
439
|
+
updateLine(toolLineId, { phase: "done", done: true });
|
|
330
440
|
}
|
|
441
|
+
for (const toolLineId of pendingToolLineIdsRef.current) {
|
|
442
|
+
updateLine(toolLineId, { phase: "done", done: true });
|
|
443
|
+
}
|
|
444
|
+
toolLineIdsRef.current.clear();
|
|
445
|
+
pendingToolLineIdsRef.current = [];
|
|
331
446
|
runningRef.current = false;
|
|
332
447
|
setRunning(false);
|
|
333
448
|
setTurnStartedAt(null);
|
|
@@ -338,22 +453,81 @@ export function ChatApp({
|
|
|
338
453
|
[agent, appendAssistantText, appendLine, moveLineToEnd, updateLine],
|
|
339
454
|
);
|
|
340
455
|
|
|
456
|
+
const runTurnRef = useRef(runTurn);
|
|
457
|
+
useEffect(() => {
|
|
458
|
+
runTurnRef.current = runTurn;
|
|
459
|
+
}, [runTurn]);
|
|
460
|
+
|
|
461
|
+
const queueRef = useRef<fastq.queueAsPromised<QueuedPrompt, void> | null>(
|
|
462
|
+
null,
|
|
463
|
+
);
|
|
464
|
+
if (!queueRef.current) {
|
|
465
|
+
queueRef.current = fastq.promise(async (item: QueuedPrompt) => {
|
|
466
|
+
if (mountedRef.current) {
|
|
467
|
+
setQueuedPrompts((prev) =>
|
|
468
|
+
prev.filter((entry) => entry.id !== item.id),
|
|
469
|
+
);
|
|
470
|
+
}
|
|
471
|
+
await runTurnRef.current(item.prompt);
|
|
472
|
+
}, 1);
|
|
473
|
+
}
|
|
474
|
+
|
|
475
|
+
useEffect(() => {
|
|
476
|
+
return () => {
|
|
477
|
+
mountedRef.current = false;
|
|
478
|
+
queueRef.current?.kill();
|
|
479
|
+
};
|
|
480
|
+
}, []);
|
|
481
|
+
|
|
482
|
+
const pushPrompt = useCallback(
|
|
483
|
+
(value: string | PromptSubmission): boolean => {
|
|
484
|
+
const normalized = normalizePromptSubmission(value);
|
|
485
|
+
const trimmed = normalized.text.trim();
|
|
486
|
+
if (!trimmed && normalized.attachments.length === 0) {
|
|
487
|
+
return false;
|
|
488
|
+
}
|
|
489
|
+
const item: QueuedPrompt = {
|
|
490
|
+
id: nowId(),
|
|
491
|
+
prompt: { ...normalized, text: trimmed },
|
|
492
|
+
};
|
|
493
|
+
setQueuedPrompts((prev) => [...prev, item]);
|
|
494
|
+
void queueRef.current?.push(item).catch((error) => {
|
|
495
|
+
if (!mountedRef.current) {
|
|
496
|
+
return;
|
|
497
|
+
}
|
|
498
|
+
setQueuedPrompts((prev) =>
|
|
499
|
+
prev.filter((entry) => entry.id !== item.id),
|
|
500
|
+
);
|
|
501
|
+
appendLine({
|
|
502
|
+
id: nowId(),
|
|
503
|
+
role: "system",
|
|
504
|
+
title: "error",
|
|
505
|
+
content: error instanceof Error ? error.message : String(error),
|
|
506
|
+
done: true,
|
|
507
|
+
});
|
|
508
|
+
});
|
|
509
|
+
return true;
|
|
510
|
+
},
|
|
511
|
+
[appendLine],
|
|
512
|
+
);
|
|
513
|
+
|
|
341
514
|
useEffect(() => {
|
|
342
515
|
if (initialPrompt && !initialRanRef.current) {
|
|
343
516
|
initialRanRef.current = true;
|
|
344
|
-
|
|
517
|
+
pushPrompt(initialPrompt);
|
|
345
518
|
}
|
|
346
|
-
}, [initialPrompt,
|
|
519
|
+
}, [initialPrompt, pushPrompt]);
|
|
347
520
|
|
|
348
521
|
const onSubmit = useCallback(
|
|
349
|
-
(value:
|
|
350
|
-
if (
|
|
522
|
+
(value: PromptSubmission) => {
|
|
523
|
+
if (pendingApproval) {
|
|
351
524
|
return;
|
|
352
525
|
}
|
|
353
|
-
|
|
354
|
-
|
|
526
|
+
if (pushPrompt(value)) {
|
|
527
|
+
setInput("");
|
|
528
|
+
}
|
|
355
529
|
},
|
|
356
|
-
[pendingApproval,
|
|
530
|
+
[pendingApproval, pushPrompt],
|
|
357
531
|
);
|
|
358
532
|
|
|
359
533
|
useInput(
|
|
@@ -390,9 +564,22 @@ export function ChatApp({
|
|
|
390
564
|
return `${String(min).padStart(2, "0")}:${String(sec).padStart(2, "0")}`;
|
|
391
565
|
}, [turnElapsedMs]);
|
|
392
566
|
|
|
567
|
+
const activeTodo = useMemo(
|
|
568
|
+
() => todoState.todos.find((todo) => todo.status === "in_progress"),
|
|
569
|
+
[todoState.todos],
|
|
570
|
+
);
|
|
571
|
+
const statusLabel =
|
|
572
|
+
running && activeTodo
|
|
573
|
+
? activeTodo.activeForm.trim() || activeTodo.content
|
|
574
|
+
: status;
|
|
575
|
+
|
|
393
576
|
return (
|
|
394
577
|
<Box flexDirection="column" width="100%" paddingX={1}>
|
|
395
578
|
<Transcript lines={lines} liveReasoning={liveReasoning} />
|
|
579
|
+
{running && todoState.visible && todoState.todos.length > 0 ? (
|
|
580
|
+
<TodoPanel todos={todoState.todos} />
|
|
581
|
+
) : null}
|
|
582
|
+
<QueuedPrompts prompts={queuedPrompts} />
|
|
396
583
|
|
|
397
584
|
{pendingApproval ? (
|
|
398
585
|
<ApprovalPrompt
|
|
@@ -404,7 +591,7 @@ export function ChatApp({
|
|
|
404
591
|
<Composer
|
|
405
592
|
input={input}
|
|
406
593
|
running={running}
|
|
407
|
-
disabled={
|
|
594
|
+
disabled={Boolean(pendingApproval)}
|
|
408
595
|
hint={INPUT_HINT}
|
|
409
596
|
onChange={setInput}
|
|
410
597
|
onSubmit={onSubmit}
|
|
@@ -414,6 +601,7 @@ export function ChatApp({
|
|
|
414
601
|
<StatusBar
|
|
415
602
|
running={running}
|
|
416
603
|
status={status}
|
|
604
|
+
statusLabel={statusLabel}
|
|
417
605
|
sessionId={sessionId}
|
|
418
606
|
elapsedLabel={elapsedLabel}
|
|
419
607
|
turnCount={turnCount}
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { Box, Text } from "ink";
|
|
2
2
|
import type { ChatLine } from "../types.ts";
|
|
3
3
|
import { lineColor } from "./shared.ts";
|
|
4
|
+
import { MarkdownMessage } from "./markdown/MarkdownMessage.tsx";
|
|
4
5
|
import { ReasoningStrip } from "./ReasoningStrip.tsx";
|
|
5
6
|
import { ThinkingStatus } from "./ThinkingStatus.tsx";
|
|
6
7
|
|
|
@@ -17,7 +18,9 @@ export function ChatMessage({ line, liveReasoning = "" }: ChatMessageProps) {
|
|
|
17
18
|
? "Assistant"
|
|
18
19
|
: (line.title ?? "System");
|
|
19
20
|
const isPendingAssistant = line.role === "assistant" && !line.done;
|
|
20
|
-
const
|
|
21
|
+
const rawText =
|
|
22
|
+
line.role === "assistant" ? line.content : line.content.trim();
|
|
23
|
+
const text = rawText || (line.done ? "(empty)" : "");
|
|
21
24
|
const shouldShowBody = Boolean(text) || !isPendingAssistant;
|
|
22
25
|
|
|
23
26
|
return (
|
|
@@ -32,15 +35,15 @@ export function ChatMessage({ line, liveReasoning = "" }: ChatMessageProps) {
|
|
|
32
35
|
<ReasoningStrip text={liveReasoning} maxVisibleLines={2} />
|
|
33
36
|
) : null}
|
|
34
37
|
{shouldShowBody ? (
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
}
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
38
|
+
line.role === "assistant" ? (
|
|
39
|
+
<MarkdownMessage streaming={isPendingAssistant}>
|
|
40
|
+
{text}
|
|
41
|
+
</MarkdownMessage>
|
|
42
|
+
) : (
|
|
43
|
+
<Text color={line.role === "user" ? undefined : lineColor(line)}>
|
|
44
|
+
{text}
|
|
45
|
+
</Text>
|
|
46
|
+
)
|
|
44
47
|
) : null}
|
|
45
48
|
</Box>
|
|
46
49
|
);
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { Box, Text } from "ink";
|
|
2
|
-
import
|
|
2
|
+
import { PromptInput } from "./PromptInput.tsx";
|
|
3
|
+
import type { PromptSubmission } from "./prompt-input/hooks/usePromptInputController.ts";
|
|
3
4
|
|
|
4
5
|
type ComposerProps = {
|
|
5
6
|
input: string;
|
|
@@ -7,7 +8,7 @@ type ComposerProps = {
|
|
|
7
8
|
disabled: boolean;
|
|
8
9
|
hint: string;
|
|
9
10
|
onChange: (value: string) => void;
|
|
10
|
-
onSubmit: (value:
|
|
11
|
+
onSubmit: (value: PromptSubmission) => void;
|
|
11
12
|
};
|
|
12
13
|
|
|
13
14
|
export function Composer({
|
|
@@ -22,12 +23,17 @@ export function Composer({
|
|
|
22
23
|
<>
|
|
23
24
|
<Box borderStyle="round" borderColor="gray" paddingX={1}>
|
|
24
25
|
<Text color="gray">{"> "}</Text>
|
|
25
|
-
<
|
|
26
|
+
<PromptInput
|
|
26
27
|
value={input}
|
|
27
28
|
onChange={onChange}
|
|
28
29
|
onSubmit={onSubmit}
|
|
29
|
-
placeholder={
|
|
30
|
+
placeholder={
|
|
31
|
+
running
|
|
32
|
+
? "Type a message (queued after current turn)"
|
|
33
|
+
: "Type a message"
|
|
34
|
+
}
|
|
30
35
|
focus={!disabled}
|
|
36
|
+
maxVisibleLines={4}
|
|
31
37
|
/>
|
|
32
38
|
</Box>
|
|
33
39
|
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
import React from "react";
|
|
2
|
+
import { Box, Text } from "ink";
|
|
3
|
+
import { splitLineAtCursor } from "./prompt-input/render.ts";
|
|
4
|
+
import {
|
|
5
|
+
usePromptInputController,
|
|
6
|
+
type PromptSubmission,
|
|
7
|
+
} from "./prompt-input/hooks/usePromptInputController.ts";
|
|
8
|
+
|
|
9
|
+
export type PromptInputProps = {
|
|
10
|
+
value: string;
|
|
11
|
+
onChange: (value: string) => void;
|
|
12
|
+
onSubmit: (value: PromptSubmission) => void;
|
|
13
|
+
placeholder?: string;
|
|
14
|
+
focus?: boolean;
|
|
15
|
+
maxVisibleLines?: number;
|
|
16
|
+
};
|
|
17
|
+
|
|
18
|
+
export function PromptInput({
|
|
19
|
+
value,
|
|
20
|
+
onChange,
|
|
21
|
+
onSubmit,
|
|
22
|
+
placeholder = "",
|
|
23
|
+
focus = true,
|
|
24
|
+
maxVisibleLines = 4,
|
|
25
|
+
}: PromptInputProps): React.JSX.Element {
|
|
26
|
+
const { view } = usePromptInputController({
|
|
27
|
+
value,
|
|
28
|
+
onChange,
|
|
29
|
+
onSubmit,
|
|
30
|
+
focus,
|
|
31
|
+
maxVisibleLines,
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
if (view.showPlaceholder) {
|
|
35
|
+
return (
|
|
36
|
+
<Text>
|
|
37
|
+
{focus ? <Text inverse> </Text> : null}
|
|
38
|
+
{placeholder ? <Text color="gray">{placeholder}</Text> : null}
|
|
39
|
+
</Text>
|
|
40
|
+
);
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
return (
|
|
44
|
+
<Box flexDirection="column">
|
|
45
|
+
{view.visibleLines.map((line, index) => {
|
|
46
|
+
const isCursorLine = focus && index === view.cursorLineInView;
|
|
47
|
+
if (!isCursorLine) {
|
|
48
|
+
return <Text key={`${view.lineOffset + index}`}>{line}</Text>;
|
|
49
|
+
}
|
|
50
|
+
const safeColumn = Math.min(Math.max(0, view.cursorCol), line.length);
|
|
51
|
+
const parts = splitLineAtCursor(line, safeColumn);
|
|
52
|
+
return (
|
|
53
|
+
<Text key={`${view.lineOffset + index}`}>
|
|
54
|
+
{parts.left}
|
|
55
|
+
<Text inverse>{parts.at}</Text>
|
|
56
|
+
{parts.right}
|
|
57
|
+
</Text>
|
|
58
|
+
);
|
|
59
|
+
})}
|
|
60
|
+
</Box>
|
|
61
|
+
);
|
|
62
|
+
}
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
import React from "react";
|
|
2
|
+
import { Box, Text, useStdout } from "ink";
|
|
3
|
+
import type { PromptSubmission } from "./prompt-input/hooks/usePromptInputController.ts";
|
|
4
|
+
|
|
5
|
+
type QueuedPromptsProps = {
|
|
6
|
+
prompts: readonly { id: string; prompt: PromptSubmission }[];
|
|
7
|
+
};
|
|
8
|
+
|
|
9
|
+
const MIN_PROMPT_PREVIEW_CHARS = 16;
|
|
10
|
+
const MAX_PROMPT_PREVIEW_CHARS = 120;
|
|
11
|
+
const ELLIPSIS = "...";
|
|
12
|
+
|
|
13
|
+
function normalizePrompt(prompt: string): string {
|
|
14
|
+
return prompt.replace(/\s+/g, " ").trim();
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
function promptPreview(prompt: PromptSubmission): string {
|
|
18
|
+
const text = normalizePrompt(prompt.text);
|
|
19
|
+
if (prompt.attachments.length === 0) {
|
|
20
|
+
return text;
|
|
21
|
+
}
|
|
22
|
+
const suffix = `${prompt.attachments.length} attachment${
|
|
23
|
+
prompt.attachments.length === 1 ? "" : "s"
|
|
24
|
+
}`;
|
|
25
|
+
return text ? `${text} (${suffix})` : suffix;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function truncatePrompt(prompt: string, maxChars: number): string {
|
|
29
|
+
if (prompt.length <= maxChars) {
|
|
30
|
+
return prompt;
|
|
31
|
+
}
|
|
32
|
+
if (maxChars <= ELLIPSIS.length) {
|
|
33
|
+
return ELLIPSIS.slice(0, maxChars);
|
|
34
|
+
}
|
|
35
|
+
return `${prompt.slice(0, maxChars - ELLIPSIS.length)}${ELLIPSIS}`;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export function QueuedPrompts({
|
|
39
|
+
prompts,
|
|
40
|
+
}: QueuedPromptsProps): React.JSX.Element | null {
|
|
41
|
+
const { stdout } = useStdout();
|
|
42
|
+
if (prompts.length === 0) {
|
|
43
|
+
return null;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
const columns = stdout?.columns ?? 80;
|
|
47
|
+
const maxPromptChars = Math.max(
|
|
48
|
+
MIN_PROMPT_PREVIEW_CHARS,
|
|
49
|
+
Math.min(MAX_PROMPT_PREVIEW_CHARS, columns - 8),
|
|
50
|
+
);
|
|
51
|
+
|
|
52
|
+
return (
|
|
53
|
+
<Box
|
|
54
|
+
flexDirection="column"
|
|
55
|
+
borderStyle="round"
|
|
56
|
+
borderColor="gray"
|
|
57
|
+
paddingX={1}
|
|
58
|
+
>
|
|
59
|
+
<Text color="gray">
|
|
60
|
+
queued {prompts.length === 1 ? "prompt" : "prompts"}
|
|
61
|
+
</Text>
|
|
62
|
+
{prompts.map((item) => {
|
|
63
|
+
const preview = truncatePrompt(
|
|
64
|
+
promptPreview(item.prompt),
|
|
65
|
+
maxPromptChars,
|
|
66
|
+
);
|
|
67
|
+
return (
|
|
68
|
+
<Text key={item.id} color="gray">
|
|
69
|
+
{"\u25cb "}
|
|
70
|
+
{preview}
|
|
71
|
+
</Text>
|
|
72
|
+
);
|
|
73
|
+
})}
|
|
74
|
+
</Box>
|
|
75
|
+
);
|
|
76
|
+
}
|