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.
Files changed (39) hide show
  1. package/README.md +4 -0
  2. package/package.json +4 -1
  3. package/src/acp/sessions/config-options.ts +27 -0
  4. package/src/acp/utils/tool-kind.ts +1 -0
  5. package/src/chat/app.tsx +210 -22
  6. package/src/chat/components/ChatMessage.tsx +13 -10
  7. package/src/chat/components/Composer.tsx +10 -4
  8. package/src/chat/components/PromptInput.tsx +62 -0
  9. package/src/chat/components/QueuedPrompts.tsx +76 -0
  10. package/src/chat/components/StatusBar.tsx +3 -1
  11. package/src/chat/components/TodoPanel.tsx +49 -0
  12. package/src/chat/components/markdown/BlockRenderer.tsx +207 -0
  13. package/src/chat/components/markdown/CodeBlock.tsx +100 -0
  14. package/src/chat/components/markdown/InlineRenderer.tsx +122 -0
  15. package/src/chat/components/markdown/MarkdownMessage.tsx +35 -0
  16. package/src/chat/components/markdown/MarkdownTable.tsx +23 -0
  17. package/src/chat/components/markdown/hooks/useMarkdownTableLayout.ts +242 -0
  18. package/src/chat/components/markdown/hooks/useMarkdownTokens.ts +46 -0
  19. package/src/chat/components/markdown/lexer.ts +100 -0
  20. package/src/chat/components/prompt-input/clipboard-image.ts +119 -0
  21. package/src/chat/components/prompt-input/hooks/usePromptInputController.ts +4 -0
  22. package/src/chat/components/prompt-input/input-model.ts +294 -0
  23. package/src/chat/components/prompt-input/paste.ts +105 -0
  24. package/src/chat/components/prompt-input/render.ts +83 -0
  25. package/src/chat/components/prompt-input/usePromptInputController.ts +493 -0
  26. package/src/chat/components/shared.ts +1 -1
  27. package/src/configure/app.tsx +17 -0
  28. package/src/core/agent/index.ts +9 -2
  29. package/src/core/approvals/allowed-tools.ts +13 -1
  30. package/src/core/config.ts +8 -0
  31. package/src/core/mcp/manager.ts +12 -0
  32. package/src/core/prompts/static/todo.md +35 -0
  33. package/src/core/prompts/system.ts +3 -0
  34. package/src/core/tools/filesystem.ts +11 -88
  35. package/src/core/tools/index.ts +1 -0
  36. package/src/core/tools/todo.ts +117 -0
  37. package/src/core/utils/attachments.ts +201 -0
  38. package/src/core/utils/paths.ts +4 -0
  39. 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.12.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: {
@@ -22,6 +22,7 @@ const KNOWN_TOOL_KINDS = new Map<string, ToolKind>([
22
22
  ["wiki_stats", "read"],
23
23
  ["wiki_search", "search"],
24
24
  ["think", "think"],
25
+ ["update_todos", "other"],
25
26
  ["get_current_time", "other"],
26
27
  ["convert_time", "other"],
27
28
  ]);
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
- const INPUT_HINT = "enter: send | esc/ctrl+c: cancel or exit";
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 toolLineIdRef = useRef<string | null>(null);
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: string) => {
191
- const trimmed = prompt.trim();
192
- if (!trimmed || runningRef.current) {
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: trimmed,
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
- for await (const event of agent.stream(trimmed)) {
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
- toolLineIdRef.current = toolId;
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
- if (toolLineIdRef.current) {
252
- updateLine(toolLineIdRef.current, {
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
- if (toolLineIdRef.current) {
328
- updateLine(toolLineIdRef.current, { phase: "done", done: true });
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
- void runTurn(initialPrompt);
517
+ pushPrompt(initialPrompt);
345
518
  }
346
- }, [initialPrompt, runTurn]);
519
+ }, [initialPrompt, pushPrompt]);
347
520
 
348
521
  const onSubmit = useCallback(
349
- (value: string) => {
350
- if (running || pendingApproval) {
522
+ (value: PromptSubmission) => {
523
+ if (pendingApproval) {
351
524
  return;
352
525
  }
353
- setInput("");
354
- void runTurn(value);
526
+ if (pushPrompt(value)) {
527
+ setInput("");
528
+ }
355
529
  },
356
- [pendingApproval, runTurn, running],
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={running || Boolean(pendingApproval)}
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 text = line.content.trim() || (line.done ? "(empty)" : "");
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
- <Text
36
- color={
37
- line.role === "user" || line.role === "assistant"
38
- ? undefined
39
- : lineColor(line)
40
- }
41
- >
42
- {text}
43
- </Text>
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 TextInput from "ink-text-input";
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: string) => void;
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
- <TextInput
26
+ <PromptInput
26
27
  value={input}
27
28
  onChange={onChange}
28
29
  onSubmit={onSubmit}
29
- placeholder={running ? "Wait for current turn..." : "Type a message"}
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
+ }