Loop and ergonomics batch across Now/Next and Maintenance:
- /undo and /redo via pre-prompt file snapshots (snapshot.ts)
- task takes a tasks[] array and runs investigations concurrently (subagent.ts)
- lazy MCP tools: mcp_list/mcp_inspect/mcp_call meta-tools, eager opt-in (mcp.ts, config.ts)
- skill tool reads its list live so a mid-session install is callable next turn (skills.ts)
- tool-name lists (tool-kinds.ts) derived from a mutating() marker; gates previously ungated writes
- prune/session recovery path summarized, and step-back doom-loop primitive (step-back.ts)
- @file completion re-walks on a slow cooldown; estimateTokens and pricing labeled as estimates
Docs: README, CHANGELOG, docs/{mcp,architecture,development} updated to match.
CI/CD: bun install-store cache and concurrency gates on both workflows; release.yml now
composes file-based release notes via scripts/make-release-notes.ts and verifies every binary.
172 lines
5.6 KiB
TypeScript
172 lines
5.6 KiB
TypeScript
import { Text, useInput } from 'ink';
|
|
import React, { useEffect, useState } from 'react';
|
|
|
|
export type PromptInputProps = {
|
|
value: string;
|
|
/** Cursor is reported alongside the value: `@path` completion needs to know where it is. */
|
|
onChange: (value: string, cursor: number) => void;
|
|
onSubmit: (value: string) => void;
|
|
placeholder?: string;
|
|
focus?: boolean;
|
|
mask?: string;
|
|
/** Newest-last list of previously submitted prompts, walked by up/down. */
|
|
history?: readonly string[];
|
|
/** Intercept a key before the input consumes it. Return true to swallow it. */
|
|
onKey?: (input: string, key: KeyLike) => boolean;
|
|
/** Where to put the cursor on mount, for a remounted input after a completion. */
|
|
initialCursor?: number;
|
|
};
|
|
|
|
type KeyLike = {
|
|
upArrow: boolean;
|
|
downArrow: boolean;
|
|
leftArrow: boolean;
|
|
rightArrow: boolean;
|
|
return: boolean;
|
|
escape: boolean;
|
|
tab: boolean;
|
|
backspace: boolean;
|
|
delete: boolean;
|
|
ctrl: boolean;
|
|
meta: boolean;
|
|
home?: boolean;
|
|
end?: boolean;
|
|
};
|
|
|
|
/**
|
|
* Text input with a real cursor and shell-style history recall.
|
|
*
|
|
* ink-text-input cannot do this: it discards up/down before its own handler and
|
|
* only ever shrinks its internal cursor offset, so an externally driven value
|
|
* leaves the cursor stranded. Owning the cursor here also gives us home/end and
|
|
* ctrl-a/e/k/u/w for free.
|
|
*/
|
|
export function PromptInput({
|
|
value,
|
|
onChange,
|
|
onSubmit,
|
|
placeholder = '',
|
|
focus = true,
|
|
mask,
|
|
history = [],
|
|
onKey,
|
|
initialCursor,
|
|
}: PromptInputProps) {
|
|
const [cursor, setCursor] = useState(initialCursor ?? value.length);
|
|
// -1 means "editing a fresh line"; 0+ indexes back from the newest entry.
|
|
const [recall, setRecall] = useState(-1);
|
|
const [stash, setStash] = useState('');
|
|
|
|
useEffect(() => {
|
|
setCursor((c) => Math.min(c, value.length));
|
|
}, [value]);
|
|
|
|
const set = (next: string, nextCursor = next.length) => {
|
|
const clamped = Math.max(0, Math.min(nextCursor, next.length));
|
|
onChange(next, clamped);
|
|
setCursor(clamped);
|
|
};
|
|
|
|
/** Position after the next run of spaces, i.e. the start of the following word. */
|
|
const wordForward = (from: number) => {
|
|
let at = from;
|
|
while (at < value.length && value[at] === ' ') at++;
|
|
while (at < value.length && value[at] !== ' ') at++;
|
|
return at;
|
|
};
|
|
|
|
/** Position before the run of spaces preceding the current word. */
|
|
const wordBack = (from: number) => {
|
|
let at = from;
|
|
while (at > 0 && value[at - 1] === ' ') at--;
|
|
while (at > 0 && value[at - 1] !== ' ') at--;
|
|
return at;
|
|
};
|
|
|
|
useInput(
|
|
(input, key) => {
|
|
if (onKey?.(input, key as KeyLike)) return;
|
|
|
|
if (key.return) {
|
|
setRecall(-1);
|
|
setStash('');
|
|
setCursor(0);
|
|
onSubmit(value);
|
|
return;
|
|
}
|
|
|
|
if (key.upArrow || key.downArrow) {
|
|
if (history.length === 0) return;
|
|
if (key.upArrow) {
|
|
const next = Math.min(recall + 1, history.length - 1);
|
|
if (recall === -1) setStash(value);
|
|
setRecall(next);
|
|
set(history[history.length - 1 - next] ?? value);
|
|
} else {
|
|
const next = recall - 1;
|
|
setRecall(next);
|
|
set(next < 0 ? stash : (history[history.length - 1 - next] ?? ''));
|
|
}
|
|
return;
|
|
}
|
|
|
|
// Word-wise motion. Terminals send ctrl-left/right as a modified arrow, but
|
|
// Ink reports some of these sequences as a plain input with ctrl held rather
|
|
// than as key.leftArrow, so both shapes are handled.
|
|
const wordLeft = key.leftArrow && (key.ctrl || key.meta);
|
|
const wordRight = key.rightArrow && (key.ctrl || key.meta);
|
|
if (wordLeft) return setCursor((c) => wordBack(c));
|
|
if (wordRight) return setCursor((c) => wordForward(c));
|
|
|
|
if (key.leftArrow) return setCursor((c) => Math.max(0, c - 1));
|
|
if (key.rightArrow) return setCursor((c) => Math.min(value.length, c + 1));
|
|
if (key.home || (key.ctrl && input === 'a')) return setCursor(0);
|
|
if (key.end || (key.ctrl && input === 'e')) return setCursor(value.length);
|
|
|
|
if (key.ctrl && input === 'k') return set(value.slice(0, cursor), cursor);
|
|
if (key.ctrl && input === 'u') return set(value.slice(cursor), 0);
|
|
// The delete key's forward cousin: without it, fixing a typo ahead of the
|
|
// cursor means walking to the end or backspacing and retyping the tail.
|
|
if (key.ctrl && input === 'd') return set(value.slice(0, cursor) + value.slice(cursor + 1), cursor);
|
|
if (key.ctrl && input === 'w') {
|
|
const upto = value.slice(0, cursor);
|
|
const trimmed = upto.replace(/\S+\s*$/, '');
|
|
return set(trimmed + value.slice(cursor), trimmed.length);
|
|
}
|
|
|
|
if (key.backspace || key.delete) {
|
|
if (cursor === 0) return;
|
|
return set(value.slice(0, cursor - 1) + value.slice(cursor), cursor - 1);
|
|
}
|
|
|
|
// Ignore remaining control sequences; a paste arrives as one multi-char input.
|
|
if (!input || key.tab || key.escape || key.meta || key.ctrl) return;
|
|
set(value.slice(0, cursor) + input + value.slice(cursor), cursor + input.length);
|
|
},
|
|
{ isActive: focus },
|
|
);
|
|
|
|
if (value.length === 0) {
|
|
if (!placeholder) return <Text inverse={focus}>{' '}</Text>;
|
|
return (
|
|
<Text dimColor>
|
|
<Text inverse={focus}>{placeholder.slice(0, 1)}</Text>
|
|
{placeholder.slice(1)}
|
|
</Text>
|
|
);
|
|
}
|
|
|
|
const shown = mask ? mask.repeat(value.length) : value;
|
|
if (!focus) return <Text>{shown}</Text>;
|
|
|
|
return (
|
|
<Text>
|
|
{shown.slice(0, cursor)}
|
|
<Text inverse>{shown.slice(cursor, cursor + 1) || ' '}</Text>
|
|
{shown.slice(cursor + 1)}
|
|
</Text>
|
|
);
|
|
}
|
|
|
|
export type { KeyLike };
|