From ef7295c153e568b5df34f7bd8de5f2e31137a9f3 Mon Sep 17 00:00:00 2001 From: notgitika Date: Wed, 30 Sep 2026 17:20:46 -0400 Subject: [PATCH] fix(tui): support cursor navigation in text areas --- src/components/FormTextArea.tsx | 78 +++++++++++++++---- .../add/harness/harness.screen.test.tsx | 27 +++++++ 2 files changed, 88 insertions(+), 17 deletions(-) diff --git a/src/components/FormTextArea.tsx b/src/components/FormTextArea.tsx index 636b750554..ef88d61b37 100644 --- a/src/components/FormTextArea.tsx +++ b/src/components/FormTextArea.tsx @@ -1,3 +1,4 @@ +import { useState } from "react"; import { Box, Text, useInput } from "ink"; import { darkTheme } from "./ui/_core.js"; @@ -16,11 +17,19 @@ export interface FormTextAreaProps { focused?: boolean; } -// FormTextArea is a minimal multiline editor: append-only typing/pasting plus -// backspace. Pasted chunks arrive as one input string whose \r become -// newlines, so multi-line paste just works. Enter inserts a newline only once -// there is content — on an empty value it is left to the parent (e.g. to -// continue a wizard step). +function Cursor({ character }: { character: string }) { + return ( + + {character} + + ); +} + +// FormTextArea is a minimal multiline editor with cursor-aware typing, +// pasting and backspace. Pasted chunks arrive as one input string whose \r +// become newlines, so multi-line paste just works. Enter inserts a newline +// only once there is content — on an empty value it is left to the parent +// (e.g. to continue a wizard step). export function FormTextArea({ name, helpText, @@ -30,27 +39,52 @@ export function FormTextArea({ previewLines = 10, focused = true, }: FormTextAreaProps) { + const [rawCursor, setRawCursor] = useState(value.length); + const cursor = Math.min(rawCursor, value.length); + useInput( (input, key) => { + if (key.leftArrow) { + setRawCursor(Math.max(0, cursor - 1)); + return; + } + if (key.rightArrow) { + setRawCursor(Math.min(value.length, cursor + 1)); + return; + } + if (key.upArrow || key.downArrow) return; + if (key.return) { - if (value !== "") onChange(value + "\n"); + if (value !== "") { + onChange(value.slice(0, cursor) + "\n" + value.slice(cursor)); + setRawCursor(cursor + 1); + } return; } if (key.backspace || key.delete) { - onChange(value.slice(0, -1)); + if (cursor === 0) return; + onChange(value.slice(0, cursor - 1) + value.slice(cursor)); + setRawCursor(cursor - 1); return; } if (key.ctrl || key.meta || key.escape) return; if (input !== "") { - onChange(value + input.replace(/\r/g, "\n")); + const next = input.replace(/\r/g, "\n"); + onChange(value.slice(0, cursor) + next + value.slice(cursor)); + setRawCursor(cursor + next.length); } }, { isActive: focused }, ); const lines = value === "" ? [] : value.split("\n"); - const hidden = Math.max(0, lines.length - previewLines); - const visible = lines.slice(hidden); + const beforeCursor = value.slice(0, cursor); + const cursorLine = beforeCursor.split("\n").length - 1; + const lastNewline = beforeCursor.lastIndexOf("\n"); + const cursorColumn = cursor - lastNewline - 1; + const hidden = Math.max(0, cursorLine - previewLines + 1); + const visible = lines.slice(hidden, hidden + previewLines); + const hiddenAfter = Math.max(0, lines.length - hidden - visible.length); return ( @@ -71,16 +105,26 @@ export function FormTextArea({ {visible.length === 0 ? ( {placeholder} - + ) : ( - visible.map((line, i) => ( - - {line} - {i === visible.length - 1 ? : null} - - )) + visible.map((line, i) => { + const lineIndex = hidden + i; + if (lineIndex !== cursorLine) return {line || " "}; + + const before = line.slice(0, cursorColumn); + const at = line[cursorColumn] ?? " "; + const after = line.slice(cursorColumn + 1); + return ( + + {before} + + {after} + + ); + }) )} + {hiddenAfter > 0 && … (+{hiddenAfter} later lines)} ); diff --git a/src/handlers/project/add/harness/harness.screen.test.tsx b/src/handlers/project/add/harness/harness.screen.test.tsx index eb556d1c3c..d7dbb2021c 100644 --- a/src/handlers/project/add/harness/harness.screen.test.tsx +++ b/src/handlers/project/add/harness/harness.screen.test.tsx @@ -157,6 +157,33 @@ describe("project add harness wizard", () => { screen.unmount(); }); + test("left and right arrows edit within the system prompt", async () => { + const projectRoot = await inProject(); + const screen = renderScreen("/agentcore/add/harness"); + + await waitForText(screen.lastFrame, "what should this harness be called?"); + await screen.write("assistant"); + await screen.press("return"); + await waitForText(screen.lastFrame, "what is the agent's system prompt?"); + + await screen.write("abde"); + await screen.press("left"); + await screen.press("left"); + await screen.write("c"); + await screen.press("right"); + await screen.write("!"); + await screen.press("ctrl+d"); + + await waitForText(screen.lastFrame, "choose a model provider"); + await acceptDefaultModel(screen); + await waitForText(screen.lastFrame, "this harness will be added to agentcore.json"); + await screen.press("return"); + await waitForText(screen.lastFrame, "added harness 'assistant'"); + + expect(await systemPromptOf(projectRoot, "assistant")).toBe("abcd!e"); + screen.unmount(); + }, 15000); + test("a provider other than Bedrock asks for its model ID and API key ARN", async () => { const projectRoot = await inProject(); const screen = renderScreen("/agentcore/add/harness");