Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
44 changes: 32 additions & 12 deletions src/tui/app.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand All @@ -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();
Expand Down Expand Up @@ -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(
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -1323,10 +1347,6 @@ export function StepCliTuiScreen(props: StepCliTuiScreenProps) {
label: "Goal",
detail: formatTuiGoalDetail(result.goal),
});
replaceComposer({
value: "",
cursorIndex: 0,
});
return true;
} catch (error) {
setStatus({
Expand Down
102 changes: 102 additions & 0 deletions src/tui/composer-state.test.ts
Original file line number Diff line number Diff line change
@@ -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>,
): 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();
});
});
15 changes: 14 additions & 1 deletion src/tui/composer-state.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,8 @@ import type {

export type StepCliTuiComposerHistoryDirection = "older" | "newer";

const MAX_COMPOSER_HISTORY_ENTRIES = 200;

export function applyComposerPaste(
composer: StepCliTuiComposerState,
text: string,
Expand Down Expand Up @@ -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,
};
Expand All @@ -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
Expand Down