Initial commit: shiro-neko 0.1.0-beta.1
Agentic coding CLI on Bun, Ink, and the AI SDK. Core: streamText loop with SDK-level tool approval so a denied call provably never executes; endpoint fallback for OpenAI reasoning models; retry with backoff. Tools: read/write/edit/glob/grep/bash, path-jailed, gitignore-aware, ripgrep with a JS fallback, binary rejection, live bash streaming. Agents: five variants crossing thinking level with tool restriction; plan and review withhold mutating tools from the model. Extensibility: frontmatter skills with on-demand bodies, plugin host with blocking hooks, MCP stdio and HTTP, read-only subagents. State: durable per-project memory, session task lists, session persistence, compaction that repairs provider-item dependencies. Distribution: five-platform cross-compiled binaries with checksums, install scripts, CI on three operating systems. 404 tests, typecheck clean.
This commit is contained in:
@@ -0,0 +1,143 @@
|
||||
import { Text, useInput } from 'ink';
|
||||
import React, { useEffect, useState } from 'react';
|
||||
|
||||
export type PromptInputProps = {
|
||||
value: string;
|
||||
onChange: (value: string) => 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;
|
||||
};
|
||||
|
||||
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;
|
||||
};
|
||||
|
||||
const INVERSE_ON = '\u001B[7m';
|
||||
const INVERSE_OFF = '\u001B[27m';
|
||||
const invert = (s: string) => `${INVERSE_ON}${s}${INVERSE_OFF}`;
|
||||
|
||||
/**
|
||||
* 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,
|
||||
}: PromptInputProps) {
|
||||
const [cursor, setCursor] = useState(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) => {
|
||||
onChange(next);
|
||||
setCursor(Math.max(0, Math.min(nextCursor, next.length)));
|
||||
};
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
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);
|
||||
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>{focus ? invert(' ') : ' '}</Text>;
|
||||
return (
|
||||
<Text dimColor>
|
||||
{focus ? invert(placeholder.slice(0, 1)) : placeholder.slice(0, 1)}
|
||||
{placeholder.slice(1)}
|
||||
</Text>
|
||||
);
|
||||
}
|
||||
|
||||
const shown = mask ? mask.repeat(value.length) : value;
|
||||
if (!focus) return <Text>{shown}</Text>;
|
||||
|
||||
return (
|
||||
<Text>
|
||||
{shown.slice(0, cursor)}
|
||||
{invert(shown.slice(cursor, cursor + 1) || ' ')}
|
||||
{shown.slice(cursor + 1)}
|
||||
</Text>
|
||||
);
|
||||
}
|
||||
|
||||
export type { KeyLike };
|
||||
Reference in New Issue
Block a user