hoomanjs 1.12.0 → 1.13.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 +29 -17
- package/package.json +2 -2
- 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/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/prompt-input/clipboard-image.ts +119 -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/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
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
<div align="center">
|
|
2
2
|
<h1>Hooman</h1>
|
|
3
3
|
<p>
|
|
4
|
-
Hooman is a Bun-powered
|
|
4
|
+
Hooman is a hackable, Bun-powered AI agent toolkit for local workflows. It is built with TypeScript, <a href="https://www.npmjs.com/package/@strands-agents/sdk">Strands Agents SDK</a>, and <a href="https://github.com/vadimdemedes/ink">Ink</a>.
|
|
5
5
|
</p>
|
|
6
6
|
<p>
|
|
7
7
|
<a href="https://bun.com"><img src="https://img.shields.io/badge/runtime-Bun-f9f1e1?logo=bun&logoColor=000000" alt="Bun" /></a>
|
|
@@ -16,12 +16,12 @@
|
|
|
16
16
|
</p>
|
|
17
17
|
</div>
|
|
18
18
|
|
|
19
|
-
It gives you:
|
|
19
|
+
It gives you a practical toolkit to build and run agent workflows:
|
|
20
20
|
|
|
21
21
|
- a one-shot `exec` command for single prompts
|
|
22
|
-
- a stateful `chat` interface for
|
|
23
|
-
- a `daemon` command for
|
|
24
|
-
- an Ink-powered `configure` workflow for
|
|
22
|
+
- a stateful `chat` interface for iterative sessions
|
|
23
|
+
- a `daemon` command for channel-driven MCP automation
|
|
24
|
+
- an Ink-powered `configure` workflow for app config, prompts, MCP servers, and installed skills
|
|
25
25
|
- an `acp` command for running Hooman as an Agent Client Protocol (ACP) agent over stdio
|
|
26
26
|
|
|
27
27
|
## Features
|
|
@@ -32,6 +32,7 @@ It gives you:
|
|
|
32
32
|
- MCP server `instructions` support: server-provided instructions are appended to the agent system prompt
|
|
33
33
|
- MCP channel notification support through `hooman daemon --channels`
|
|
34
34
|
- Skill discovery / install / removal through the integrated configure flow
|
|
35
|
+
- Toolkit-oriented architecture with configurable tools, prompts, memory, and transports
|
|
35
36
|
- Interactive terminal UI for chat and configuration
|
|
36
37
|
|
|
37
38
|
## Requirements
|
|
@@ -157,18 +158,21 @@ hooman daemon --channels --yolo
|
|
|
157
158
|
|
|
158
159
|
### Feature Flags
|
|
159
160
|
|
|
160
|
-
Runtime tools and prompt sections are controlled from `config.json` under `
|
|
161
|
+
Runtime tools and prompt sections are controlled from `config.json` under `tools`:
|
|
161
162
|
|
|
162
|
-
- `
|
|
163
|
-
- `
|
|
164
|
-
- `
|
|
165
|
-
- `
|
|
166
|
-
- `
|
|
163
|
+
- `tools.todo.enabled`
|
|
164
|
+
- `tools.fetch.enabled`
|
|
165
|
+
- `tools.filesystem.enabled`
|
|
166
|
+
- `tools.shell.enabled`
|
|
167
|
+
- `tools.ltm.enabled`
|
|
168
|
+
- `tools.wiki.enabled`
|
|
169
|
+
- `tools.mcp.enabled` (enables MCP management tools + prefixed MCP server tools/instructions)
|
|
170
|
+
- `tools.skills.enabled` (enables skills management tools + skills prompt sections)
|
|
167
171
|
|
|
168
172
|
Both `ltm` and `wiki` include dedicated Chroma settings under:
|
|
169
173
|
|
|
170
|
-
- `
|
|
171
|
-
- `
|
|
174
|
+
- `tools.ltm.chroma` (default collection: `memory`)
|
|
175
|
+
- `tools.wiki.chroma` (default collection: `wiki`)
|
|
172
176
|
|
|
173
177
|
### `hooman configure`
|
|
174
178
|
|
|
@@ -211,7 +215,7 @@ Hooman stores its data in:
|
|
|
211
215
|
|
|
212
216
|
Important files and folders:
|
|
213
217
|
|
|
214
|
-
- `config.json` - app name, LLM provider/model, tool
|
|
218
|
+
- `config.json` - app name, LLM provider/model, tool flags, LTM/wiki settings, compaction
|
|
215
219
|
- `instructions.md` - system instructions used to build the agent prompt
|
|
216
220
|
- `mcp.json` - MCP server definitions
|
|
217
221
|
- `skills/` - installed skills
|
|
@@ -231,9 +235,9 @@ This is the shape managed by `hooman configure`:
|
|
|
231
235
|
"params": {}
|
|
232
236
|
},
|
|
233
237
|
"tools": {
|
|
234
|
-
"
|
|
235
|
-
|
|
236
|
-
|
|
238
|
+
"todo": {
|
|
239
|
+
"enabled": true
|
|
240
|
+
},
|
|
237
241
|
"fetch": {
|
|
238
242
|
"enabled": true
|
|
239
243
|
},
|
|
@@ -260,6 +264,12 @@ This is the shape managed by `hooman configure`:
|
|
|
260
264
|
"wiki": "wiki"
|
|
261
265
|
}
|
|
262
266
|
}
|
|
267
|
+
},
|
|
268
|
+
"mcp": {
|
|
269
|
+
"enabled": false
|
|
270
|
+
},
|
|
271
|
+
"skills": {
|
|
272
|
+
"enabled": false
|
|
263
273
|
}
|
|
264
274
|
},
|
|
265
275
|
"compaction": {
|
|
@@ -269,6 +279,8 @@ This is the shape managed by `hooman configure`:
|
|
|
269
279
|
}
|
|
270
280
|
```
|
|
271
281
|
|
|
282
|
+
Tool approvals are session-scoped and are not persisted in `config.json`.
|
|
283
|
+
|
|
272
284
|
Supported `llm.provider` values:
|
|
273
285
|
|
|
274
286
|
- `ollama`
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "hoomanjs",
|
|
3
|
-
"version": "1.
|
|
4
|
-
"description": "Bun-powered
|
|
3
|
+
"version": "1.13.0",
|
|
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",
|
|
7
7
|
"email": "contact@vaibhavpandey.com"
|
|
@@ -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/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,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/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/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
|
+
}
|