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 CHANGED
@@ -1,7 +1,7 @@
1
1
  <div align="center">
2
2
  <h1>Hooman</h1>
3
3
  <p>
4
- Hooman is a Bun-powered local AI agent CLI 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>.
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 interactive sessions
23
- - a `daemon` command for processing MCP channel notifications in background
24
- - an Ink-powered `configure` workflow for editing app config, `instructions.md`, MCP servers, and installed skills
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 `features`:
161
+ Runtime tools and prompt sections are controlled from `config.json` under `tools`:
161
162
 
162
- - `features.fetch.enabled`
163
- - `features.filesystem.enabled`
164
- - `features.shell.enabled`
165
- - `features.ltm.enabled`
166
- - `features.wiki.enabled`
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
- - `features.ltm.chroma` (default collection: `memory`)
171
- - `features.wiki.chroma` (default collection: `wiki`)
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 approvals, feature flags, LTM/wiki settings, compaction
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
- "allowed": []
235
- },
236
- "features": {
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.12.0",
4
- "description": "Bun-powered local AI agent CLI with chat, exec, ACP, MCP, and skills support.",
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: {
@@ -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/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,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/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/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
+ }