import * as fsOld from "fs"; import React, { useState, useCallback, useMemo, useEffect, useRef, createContext, useContext } from "react"; import { Text, Box, Static, measureElement, DOMElement, useInput, useApp } from "ink"; import clipboardy from "clipboardy"; import { InputWithHistory } from "./components/input-with-history.tsx"; import { t } from "structural"; import { Config, Metadata, ConfigContext, ConfigPathContext, SetConfigContext, useConfig } from "./config.ts"; import { HistoryItem, AssistantItem, ToolCallItem } from "./history.ts"; import Loading from "./components/loading.tsx"; import { Header } from "./header.tsx"; import { UnchainedContext, useColor, useUnchained } from "./theme.ts"; import { DiffRenderer } from "./components/diff-renderer.tsx"; import { FileRenderer } from "./components/file-renderer.tsx"; import { shell, read, list, edit, append, prepend, rewrite, create as createTool, mcp, fetch as fetchTool, SKIP_CONFIRMATION, } from "./tools/index.ts"; import { useShallow } from "zustand/react/shallow"; import SelectInput from "./components/ink/select-input.tsx"; import { useAppStore, RunArgs, useModel } from "./state.ts"; import { LaissCodex } from "./components/laisscodex.tsx"; import { IndicatorComponent, ItemComponent } from "./components/select.tsx"; import { Menu } from "./menu.tsx"; import { displayLog } from "./logger.ts"; import { CenteredBox } from "./components/centered-box.tsx"; import { Transport } from "./transports/transport-common.ts"; import { LocalTransport } from "./transports/local.ts"; import { markUpdatesSeen } from "./update-notifs/update-notifs.ts"; import { useCtrlC, ExitOnDoubleCtrlC, useCtrlCPressed } from "./components/exit-on-double-ctrl-c.tsx"; import { InputHistory } from "./input-history/index.ts"; import { Markdown } from "./markdown/index.tsx"; import { countLines } from "./str.ts"; type Props = { config: Config; configPath: string, metadata: Metadata, updates: string | null, unchained: boolean, transport: Transport, inputHistory: InputHistory, }; type StaticItem = { type: "header", } | { type: "version", metadata: Metadata, config: Config, } | { type: "updates", updates: string, } | { type: "slogan", } | { type: "history-item", item: HistoryItem, }; function toStaticItems(messages: HistoryItem[]): Array { return messages.map(message => ({ type: "history-item", item: message, })); } const TransportContext = createContext(new LocalTransport()); export default function App({ config, configPath, metadata, unchained, transport, updates, inputHistory }: Props) { const [ currConfig, setCurrConfig ] = useState(config); const { history, modeData } = useAppStore( useShallow(state => ({ history: state.history, modeData: state.modeData, modelOverride: state.modelOverride, })) ); useEffect(() => { if(updates != null) markUpdatesSeen(); }, []); const staticItems: StaticItem[] = useMemo(() => { return [ { type: "header" }, { type: "version", metadata, config: currConfig }, ...(updates ? [{ type: "updates" as const, updates }] : []), { type: "slogan" }, ...toStaticItems(history), ] }, [ history ]); return { (item, index) => } { modeData.mode === "responding" && (modeData.inflightResponse.reasoningContent || modeData.inflightResponse.content) && } } function BottomBar({ inputHistory, metadata }: { inputHistory: InputHistory metadata: Metadata, }) { const [ versionCheck, setVersionCheck ] = useState("Checking for updates..."); const themeColor = useColor(); const ctrlCPressed = useCtrlCPressed(); const { modeData } = useAppStore( useShallow(state => ({ modeData: state.modeData, })) ); useEffect(() => { getLatestVersion().then(latestVersion => { if(latestVersion && metadata.version < latestVersion) { setVersionCheck("New version released! Run `npm install -g --omit=dev laisscodex` to update."); return; } setVersionCheck("LaissCodex is up-to-date."); setTimeout(() => { setVersionCheck(""); }, 5000); }); }, [ metadata ]); if(modeData.mode === "menu") return return { ctrlCPressed && "Press Ctrl+C again to exit." } {versionCheck} } const PackageSchema = t.subtype({ "dist-tags": t.subtype({ latest: t.str, }), }); async function getLatestVersion() { try { const response = await fetch("https://registry.npmjs.com/laisscodex"); const contents = await response.json(); const packageInfo = PackageSchema.slice(contents); return packageInfo["dist-tags"].latest; } catch { return null; } } function BottomBarContent({ inputHistory }: { inputHistory: InputHistory }) { const config = useConfig(); const transport = useContext(TransportContext); const [ query, setQuery ] = useState(""); const { modeData, input, abortResponse, toggleMenu, byteCount } = useAppStore( useShallow(state => ({ modeData: state.modeData, input: state.input, abortResponse: state.abortResponse, toggleMenu: state.toggleMenu, byteCount: state.byteCount, })) ); useCtrlC(() => { setQuery(""); }); useInput((_, key) => { if(key.escape) { abortResponse(); toggleMenu(); } }); const color = useColor(); const onSubmit = useCallback(async () => { setQuery(""); await input({ query, config, transport }); }, [ query, config, transport ]); if(modeData.mode === "responding") { return { byteCount === 0 ? null : ⇩ {byteCount} bytes } {" "} (Press ESC to interrupt) ; } if(modeData.mode === "error-recovery") return ; if(modeData.mode === "diff-apply") { return }; if(modeData.mode === "fix-json") { return }; if(modeData.mode === "tool-waiting") { return } if(modeData.mode === "payment-error") { return } if(modeData.mode === "rate-limit-error") { return } if(modeData.mode === "request-error") { return } if(modeData.mode === "tool-request") { return ; } const _: "menu" | "input" = modeData.mode; return (Press ESC to enter the menu) } function RequestErrorScreen({ error, curlCommand }: { error: string, curlCommand: string | null }) { const config = useConfig(); const transport = useContext(TransportContext); const { retryFrom } = useAppStore( useShallow(state => ({ retryFrom: state.retryFrom, })) ); const { exit } = useApp(); const [viewError, setViewError] = useState(false); const [copiedCurl, setCopiedCurl] = useState(false); const [clipboardError, setClipboardError] = useState(null); const items = [ { label: "View error", value: "view" as const, }, ... ( curlCommand ? [ { label: copiedCurl ? "Copied cURL!" : "Copy failed request as cURL", value: "copy-curl" as const, } ] : [] ), { label: "Retry", value: "retry" as const, }, { label: "Quit LaissCodex", value: "quit" as const, }, ].filter(item => { if(viewError && item.value === "view") return false; return true; }); const onSelect = useCallback((item: (typeof items)[number]) => { if(item.value === "view") { setViewError(true); } else if(item.value === "copy-curl") { try { clipboardy.writeSync(curlCommand || "Failed to generate cURL command"); setCopiedCurl(true); } catch (error) { setClipboardError(error instanceof Error ? error.message : "Failed to copy to clipboard"); } } else if(item.value === "retry") { retryFrom("request-error", { config, transport }); } else { const _: "quit" = item.value; exit(); } }, [curlCommand]); return It looks like you've hit a request error! { viewError && { error } } { copiedCurl && { curlCommand } } { clipboardError && { clipboardError } } } function RateLimitErrorScreen({ error }: { error: string }) { const config = useConfig(); const transport = useContext(TransportContext); const { retryFrom } = useAppStore( useShallow(state => ({ retryFrom: state.retryFrom, })) ); useInput(() => { retryFrom("rate-limit-error", { config, transport }); }); return It looks like you've hit a rate limit! Here's the error from the backend: {error} Press any key when you're ready to retry. } function PaymentErrorScreen({ error }: { error: string }) { const config = useConfig(); const transport = useContext(TransportContext); const { retryFrom } = useAppStore( useShallow(state => ({ retryFrom: state.retryFrom, })) ); useInput(() => { retryFrom("payment-error", { config, transport }); }); return Payment error: {error} Once you've paid, press any key to continue. } function ToolRequestRenderer({ toolReq, config, transport }: { toolReq: ToolCallItem } & RunArgs) { const themeColor = useColor(); const { runTool, rejectTool } = useAppStore( useShallow(state => ({ runTool: state.runTool, rejectTool: state.rejectTool, })) ); const unchained = useUnchained(); const prompt = (() => { const fn = toolReq.tool.function; switch (fn.name) { case "create": return Create file {fn.arguments.filePath} ? case "rewrite": case "append": case "prepend": case "edit": return Make these changes to {fn.arguments.filePath} ? case "read": case "shell": case "fetch": case "list": case "mcp": return null; } })(); const items = [ { label: "Yes", value: "yes", }, { label: "No, and tell LaissCodex what to do differently", value: "no", }, ]; const onSelect = useCallback(async (item: (typeof items)[number]) => { if(item.value === "no") rejectTool(toolReq.tool.toolCallId); else await runTool({ toolReq, config, transport }); }, [ toolReq, config, transport ]); const noConfirm = unchained || SKIP_CONFIRMATION.includes(toolReq.tool.function.name); useEffect(() => { if(noConfirm) { runTool({ toolReq, config, transport }); } }, [ toolReq, noConfirm, config, transport ]); if(noConfirm) return ; return { prompt } } const StaticItemRenderer = React.memo(({ item }: { item: StaticItem }) => { const themeColor = useColor(); const model = useModel(); if(item.type === "header") return
; if(item.type === "version") { return Model: {model.nickname} Version: {item.metadata.version} } if(item.type === "slogan") { return LaissCodex is your friend. Tell LaissCodex what you want to do. } if(item.type === "updates") { return Updates: Thanks for updating! See the full changelog by running: `laisscodex changelog` } return }); const MessageDisplay = React.memo(({ item }: { item: HistoryItem | Omit // Allow inflight assistant messages }) => { return }); const MessageDisplayInner = React.memo(({ item }: { item: HistoryItem | Omit // Allow inflight assistant messages }) => { if(item.type === "notification") { return {item.content} } if(item.type === "assistant") { return } if(item.type === "tool") { return } if(item.type === "tool-output") { const lines = (() => { if(item.result.lines == null) return item.result.content.split("\n").length; return item.result.lines; })(); return Got {lines} lines of output } if(item.type === "tool-malformed") { return { displayLog({ verbose: `Error: ${item.error}`, info: "Malformed tool call. Retrying...", }) } } if(item.type === "tool-failed") { return { displayLog({ verbose: `Error: ${item.error}`, info: "Tool returned an error...", }) } } if(item.type === "tool-reject") { return Tool rejected; tell LaissCodex what to do instead: } if(item.type === "file-outdated") { return File was modified since it was last read; re-reading... } if(item.type === "file-unreadable") { return File could not be read — has it been deleted? } if(item.type === "request-failed") { return Request failed. } // Type assertion proving we've handled all types other than user const _: "user" = item.type; return {item.content} }); function ToolMessageRenderer({ item }: { item: ToolCallItem }) { switch(item.tool.function.name) { case "read": return case "list": return case "shell": return case "edit": return case "create": return case "mcp": return case "fetch": return case "append": return case "prepend": return case "rewrite": return } } function AppendToolRenderer({ item }: { item: t.GetType }) { const { filePath, text } = item.arguments; const file = fsOld.readFileSync(filePath, "utf8"); const lines = countLines(file); return LaissCodex wants to add the following to the end of the file: } function FetchToolRenderer({ item }: { item: t.GetType }) { const themeColor = useColor(); return {item.name}: {item.arguments.url} } function ShellToolRenderer({ item }: { item: t.GetType }) { const themeColor = useColor(); return {item.name}: {item.arguments.cmd} timeout: {item.arguments.timeout} } function ReadToolRenderer({ item }: { item: t.GetType }) { const themeColor = useColor(); return {item.name}: {item.arguments.filePath} } function ListToolRenderer({ item }: { item: t.GetType }) { const themeColor = useColor(); return {item.name}: {item?.arguments?.dirPath || process.cwd()} } function EditToolRenderer({ item }: { item: t.GetType }) { const themeColor = useColor(); return Edit: {item.arguments.filePath} } function PrependToolRenderer({ item }: { item: t.GetType }) { const { text, filePath } = item.arguments; return LaissCodex wants to add the following to the beginning of the file: } function RewriteToolRenderer({ item }: { item: t.GetType }) { const { text, filePath } = item.arguments; return LaissCodex wants to rewrite the file: } function DiffEditRenderer({ item, filePath }: { item: t.GetType, filePath: string, }) { return LaissCodex wants to make the following changes: } function CreateToolRenderer({ item }: { item: t.GetType }) { const themeColor = useColor(); return LaissCodex wants to create {item.arguments.filePath} : } function McpToolRenderer({ item }: { item: t.GetType }) { const themeColor = useColor(); return {item.name}: Server: {item.arguments.server}, Tool: {item.arguments.tool} Arguments: {JSON.stringify(item.arguments.arguments)} } const MAX_THOUGHTBOX_HEIGHT = 8; const MAX_THOUGHTBOX_WIDTH = 80; function AssistantMessageRenderer({ item }: { item: Omit, }) { const thoughtsRef = useRef(null); const [ thoughtsHeight, setThoughtsHeight ] = useState(0); let thoughts = item.reasoningContent; let content = item.content.trim(); useEffect(() => { if(thoughtsRef.current) { const { height } = measureElement(thoughtsRef.current); setThoughtsHeight(height); } }, [ thoughts ]); const thoughtsOverflow = thoughtsHeight - (MAX_THOUGHTBOX_HEIGHT - 2); return { thoughts && thoughts !== "" && 0 ? MAX_THOUGHTBOX_HEIGHT : undefined} width={MAX_THOUGHTBOX_WIDTH} overflowY="hidden" flexDirection="column" borderColor="gray" borderStyle="round" > {thoughts} } }