diff --git a/src/tui/app.tsx b/src/tui/app.tsx index 2d7a534..5b2b7e9 100644 --- a/src/tui/app.tsx +++ b/src/tui/app.tsx @@ -613,7 +613,16 @@ export function StepCliTuiScreen(props: StepCliTuiScreenProps) { const currentComposer = readComposerSnapshot(); if (currentComposer.value.includes("\n")) { - return; + const firstLineEnd = currentComposer.value.indexOf("\n"); + if (currentComposer.cursorIndex > firstLineEnd) { + return; + } + + if (currentComposer.cursorIndex > 0) { + key.preventDefault(); + replaceComposer({ ...currentComposer, cursorIndex: 0 }); + return; + } } key.preventDefault(); @@ -638,7 +647,19 @@ export function StepCliTuiScreen(props: StepCliTuiScreenProps) { const currentComposer = readComposerSnapshot(); if (currentComposer.value.includes("\n")) { - return; + const lastLineStart = currentComposer.value.lastIndexOf("\n") + 1; + if (currentComposer.cursorIndex < lastLineStart) { + return; + } + + if (currentComposer.cursorIndex < currentComposer.value.length) { + key.preventDefault(); + replaceComposer({ + ...currentComposer, + cursorIndex: currentComposer.value.length, + }); + return; + } } key.preventDefault(); @@ -697,11 +718,14 @@ export function StepCliTuiScreen(props: StepCliTuiScreenProps) { if (isComposerSubmitKey(key)) { key.preventDefault(); - const autocompleteSlashCommand = shouldAutocompleteSlashCommand( - slashPaletteState, - ) - ? slashPaletteState.activeCommand - : null; + const composerHasArguments = /^\S+\s+\S/.test( + readComposerSnapshot().value.trimStart(), + ); + const autocompleteSlashCommand = + !composerHasArguments && + shouldAutocompleteSlashCommand(slashPaletteState) + ? slashPaletteState.activeCommand + : null; if (autocompleteSlashCommand) { applyComposerEdit( applySlashCommandSelection( @@ -865,7 +889,7 @@ export function StepCliTuiScreen(props: StepCliTuiScreenProps) { if (trimmed.startsWith("/")) { const clearComposer = await handleSlashCommand(trimmed); - if (clearComposer) { + if (clearComposer && readComposerSnapshot().value === inputContent) { applyComposerEdit({ value: "", cursorIndex: 0, @@ -1323,10 +1347,6 @@ export function StepCliTuiScreen(props: StepCliTuiScreenProps) { label: "Goal", detail: formatTuiGoalDetail(result.goal), }); - replaceComposer({ - value: "", - cursorIndex: 0, - }); return true; } catch (error) { setStatus({ diff --git a/src/tui/composer-state.test.ts b/src/tui/composer-state.test.ts new file mode 100644 index 0000000..3378bcf --- /dev/null +++ b/src/tui/composer-state.test.ts @@ -0,0 +1,102 @@ +import { describe, it, expect } from "vitest"; +import { + browseComposerHistory, + rememberSubmittedComposerValue, +} from "./composer-state.js"; +import type { + StepCliTuiComposerHistoryState, + StepCliTuiComposerState, +} from "./types.js"; + +const emptyHistory: StepCliTuiComposerHistoryState = { + entries: [], + browsingIndex: null, + draftBeforeBrowsing: null, +}; + +function composerOf(value: string, cursorIndex = value.length) { + return { value, cursorIndex }; +} + +function historyOf( + values: string[], + overrides?: Partial, +): StepCliTuiComposerHistoryState { + return { + entries: values.map((value) => composerOf(value)), + browsingIndex: null, + draftBeforeBrowsing: null, + ...overrides, + }; +} + +describe("rememberSubmittedComposerValue", () => { + it("appends a submitted value", () => { + const next = rememberSubmittedComposerValue( + emptyHistory, + composerOf("hello"), + ); + expect(next.entries.map((e) => e.value)).toEqual(["hello"]); + expect(next.browsingIndex).toBeNull(); + }); + + it("skips consecutive duplicate submissions", () => { + const next = rememberSubmittedComposerValue( + historyOf(["hello"]), + composerOf("hello"), + ); + expect(next.entries.map((e) => e.value)).toEqual(["hello"]); + }); + + it("resets browsing state when skipping a duplicate", () => { + const next = rememberSubmittedComposerValue( + historyOf(["hello"], { + browsingIndex: 0, + draftBeforeBrowsing: composerOf("draft"), + }), + composerOf("hello"), + ); + expect(next.browsingIndex).toBeNull(); + expect(next.draftBeforeBrowsing).toBeNull(); + }); + + it("caps history at 200 entries", () => { + const full = historyOf(Array.from({ length: 200 }, (_, i) => `entry-${i}`)); + const next = rememberSubmittedComposerValue(full, composerOf("newest")); + expect(next.entries).toHaveLength(200); + expect(next.entries[0]!.value).toBe("entry-1"); + expect(next.entries.at(-1)!.value).toBe("newest"); + }); +}); + +describe("browseComposerHistory", () => { + it("absorbs older navigation at the oldest entry", () => { + const history = historyOf(["a", "b"], { browsingIndex: 0 }); + const composer: StepCliTuiComposerState = { value: "a", cursorIndex: 0 }; + const next = browseComposerHistory(history, composer, "older"); + expect(next.history).toBe(history); + expect(next.composer).toBe(composer); + }); + + it("walks older entries and stores the draft", () => { + const next = browseComposerHistory( + historyOf(["a", "b"]), + composerOf("draft"), + "older", + ); + expect(next.composer.value).toBe("b"); + expect(next.history.browsingIndex).toBe(1); + expect(next.history.draftBeforeBrowsing?.value).toBe("draft"); + }); + + it("restores the draft when browsing past the newest entry", () => { + const history = historyOf(["a"], { + browsingIndex: 0, + draftBeforeBrowsing: composerOf("draft"), + }); + const next = browseComposerHistory(history, composerOf("a"), "newer"); + expect(next.composer.value).toBe("draft"); + expect(next.history.browsingIndex).toBeNull(); + expect(next.history.draftBeforeBrowsing).toBeNull(); + }); +}); diff --git a/src/tui/composer-state.ts b/src/tui/composer-state.ts index 578165f..c378dab 100644 --- a/src/tui/composer-state.ts +++ b/src/tui/composer-state.ts @@ -5,6 +5,8 @@ import type { export type StepCliTuiComposerHistoryDirection = "older" | "newer"; +const MAX_COMPOSER_HISTORY_ENTRIES = 200; + export function applyComposerPaste( composer: StepCliTuiComposerState, text: string, @@ -34,8 +36,15 @@ export function rememberSubmittedComposerValue( return detachComposerHistory(history); } + const lastEntry = history.entries[history.entries.length - 1]; + if (lastEntry?.value === composer.value) { + return detachComposerHistory(history); + } + return { - entries: [...history.entries, cloneComposerState(composer)], + entries: [...history.entries, cloneComposerState(composer)].slice( + -MAX_COMPOSER_HISTORY_ENTRIES, + ), browsingIndex: null, draftBeforeBrowsing: null, }; @@ -54,6 +63,10 @@ export function browseComposerHistory( } if (direction === "older") { + if (history.browsingIndex === 0) { + return { history, composer }; + } + const nextIndex = history.browsingIndex === null ? history.entries.length - 1