Initial commit: sage:TypeScript AI Agent 框架 monorepo(llm / memory / tui 三包)
This commit is contained in:
@@ -0,0 +1,28 @@
|
||||
{
|
||||
"name": "@sage/tui",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"start": "node --import=tsx/esm src/index.tsx",
|
||||
"dev": "node --import=tsx/esm --watch src/index.tsx",
|
||||
"typecheck": "tsc --noEmit"
|
||||
},
|
||||
"dependencies": {
|
||||
"@sage/core": "*",
|
||||
"@sage/llm": "*",
|
||||
"@sage/memory": "*",
|
||||
"ink": "^5.1.0",
|
||||
"ink-text-input": "^6.0.0",
|
||||
"react": "^18.3.0",
|
||||
"chalk": "^5.4.0",
|
||||
"figures": "^6.1.0",
|
||||
"zod": "^3.24.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^22.0.0",
|
||||
"@types/react": "^18.3.0",
|
||||
"tsx": "^4.19.0",
|
||||
"typescript": "^5.8.0"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,101 @@
|
||||
import React from "react"
|
||||
import { Box, Text, useInput } from "ink"
|
||||
import { colors } from "./theme/index.js"
|
||||
import { MessageList } from "./components/MessageList.js"
|
||||
import { InputBar } from "./components/InputBar.js"
|
||||
import { MemoryBar } from "./components/MemoryBar.js"
|
||||
import { MemoryPanel } from "./components/MemoryPanel.js"
|
||||
import { SessionOverlay } from "./components/SessionOverlay.js"
|
||||
import { Footer } from "./components/Footer.js"
|
||||
import { useAppState } from "./hooks/useAppState.js"
|
||||
import { useState } from "react"
|
||||
|
||||
export function App() {
|
||||
const [sessionCursor, setSessionCursor] = useState(0)
|
||||
const { state, patch, submitMessage, selectSession, newSession, removeSession, abort, config } = useAppState()
|
||||
|
||||
useInput((_input, key) => {
|
||||
if (state.openOverlay) return
|
||||
if (key.ctrl && _input === "k") { patch({ openOverlay: "sessions" }); return }
|
||||
if (key.escape && state.error) { patch({ error: null }); return }
|
||||
})
|
||||
|
||||
const model = state.currentSession?.model ?? config.defaultModel
|
||||
const provider = state.currentSession?.provider ?? config.defaultProvider
|
||||
const cwd = state.currentSession?.cwd ?? process.cwd()
|
||||
|
||||
if (state.openOverlay === "sessions") {
|
||||
return (
|
||||
<SessionOverlay
|
||||
sessions={state.sessions}
|
||||
cursor={sessionCursor}
|
||||
setCursor={setSessionCursor}
|
||||
onSelect={s => { selectSession(s); patch({ openOverlay: null }) }}
|
||||
onNew={() => { newSession(); patch({ openOverlay: null }) }}
|
||||
onDelete={id => { removeSession(id); setSessionCursor(c => Math.max(0, c - 1)) }}
|
||||
onClose={() => patch({ openOverlay: null })}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
if (state.openOverlay === "memories") {
|
||||
return <MemoryPanel onClose={() => patch({ openOverlay: null })} />
|
||||
}
|
||||
|
||||
return (
|
||||
<Box flexDirection="column">
|
||||
{/* Session title */}
|
||||
<Box paddingLeft={2} paddingRight={2} paddingTop={1} marginBottom={1}>
|
||||
<Text color={colors.muted}>sage </Text>
|
||||
{state.currentSession
|
||||
? <Text color={colors.text}>{state.currentSession.title}</Text>
|
||||
: <Text color={colors.subtle}>no session</Text>
|
||||
}
|
||||
</Box>
|
||||
|
||||
{/* Welcome */}
|
||||
{!state.currentSession && (
|
||||
<Box flexDirection="column" paddingLeft={2} marginBottom={1}>
|
||||
<Text color={colors.subtle}>start typing to begin, or ^K to switch sessions</Text>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{/* Messages */}
|
||||
{state.currentSession && (
|
||||
<MessageList
|
||||
messages={state.messages}
|
||||
toolCalls={state.toolCalls}
|
||||
streamBuffer={state.streamBuffer}
|
||||
isStreaming={state.isStreaming}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Error/info */}
|
||||
{state.error && (
|
||||
<Box paddingLeft={2} marginTop={1}>
|
||||
<Text color={state.error.startsWith("✓") ? colors.green : colors.red}>
|
||||
{state.error}
|
||||
</Text>
|
||||
{!state.error.startsWith("✓") && <Text color={colors.subtle}> esc</Text>}
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{/* Memory recall indicator */}
|
||||
<MemoryBar memories={state.recalledMemories} />
|
||||
|
||||
{/* Input */}
|
||||
<InputBar onSubmit={submitMessage} isStreaming={state.isStreaming} onAbort={abort} />
|
||||
|
||||
{/* Footer */}
|
||||
<Footer
|
||||
model={model}
|
||||
provider={provider}
|
||||
cwd={cwd}
|
||||
isStreaming={state.isStreaming}
|
||||
usage={state.usage}
|
||||
memoryCount={state.recalledMemories.length}
|
||||
sessionTitle={state.currentSession?.title ?? null}
|
||||
/>
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
import React from "react"
|
||||
import { Box, Text } from "ink"
|
||||
import { colors, sym } from "../theme/index.js"
|
||||
import { basename } from "path"
|
||||
|
||||
interface Props {
|
||||
model: string
|
||||
provider: string
|
||||
cwd: string
|
||||
isStreaming: boolean
|
||||
usage: { input: number; output: number }
|
||||
memoryCount: number
|
||||
sessionTitle: string | null
|
||||
}
|
||||
|
||||
export function Footer({ model, provider, cwd, isStreaming, usage, memoryCount, sessionTitle }: Props) {
|
||||
const dir = basename(cwd) || cwd
|
||||
const modelShort = model.replace(/claude-/, "").replace(/-\d{8}$/, "")
|
||||
|
||||
return (
|
||||
<Box flexDirection="column">
|
||||
{/* divider */}
|
||||
<Box paddingLeft={2} paddingRight={2}>
|
||||
<Text color={colors.subtle}>
|
||||
{"─".repeat(60)}
|
||||
</Text>
|
||||
</Box>
|
||||
|
||||
{/* status line */}
|
||||
<Box paddingLeft={2} paddingRight={2} justifyContent="space-between">
|
||||
<Box gap={2}>
|
||||
<Text color={colors.muted}>{provider}</Text>
|
||||
<Text color={colors.subtle}>{sym.dot}</Text>
|
||||
<Text color={colors.muted}>{modelShort}</Text>
|
||||
{isStreaming && <Text color={colors.orange}>{sym.dot} streaming</Text>}
|
||||
</Box>
|
||||
<Box gap={2}>
|
||||
{memoryCount > 0 && (
|
||||
<Text color={colors.yellow}>{sym.memory} {memoryCount}</Text>
|
||||
)}
|
||||
{usage.output > 0 && (
|
||||
<Text color={colors.subtle}>{usage.output}tok</Text>
|
||||
)}
|
||||
<Text color={colors.muted}>{dir}</Text>
|
||||
</Box>
|
||||
</Box>
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
import React, { useState, useCallback } from "react"
|
||||
import { Box, Text, useInput } from "ink"
|
||||
import TextInput from "ink-text-input"
|
||||
import { colors, sym } from "../theme/index.js"
|
||||
|
||||
interface Props {
|
||||
onSubmit: (text: string) => void
|
||||
isStreaming: boolean
|
||||
onAbort: () => void
|
||||
}
|
||||
|
||||
export function InputBar({ onSubmit, isStreaming, onAbort }: Props) {
|
||||
const [value, setValue] = useState("")
|
||||
const [history, setHistory] = useState<string[]>([])
|
||||
const [historyIdx, setHistoryIdx] = useState(-1)
|
||||
|
||||
useInput((input, key) => {
|
||||
if (isStreaming && key.ctrl && input === "c") { onAbort(); return }
|
||||
|
||||
if (!isStreaming && key.upArrow) {
|
||||
const idx = Math.min(historyIdx + 1, history.length - 1)
|
||||
if (idx >= 0) { setHistoryIdx(idx); setValue(history[history.length - 1 - idx] ?? "") }
|
||||
}
|
||||
if (!isStreaming && key.downArrow) {
|
||||
const idx = historyIdx - 1
|
||||
if (idx < 0) { setHistoryIdx(-1); setValue("") }
|
||||
else { setHistoryIdx(idx); setValue(history[history.length - 1 - idx] ?? "") }
|
||||
}
|
||||
})
|
||||
|
||||
const handleSubmit = useCallback((text: string) => {
|
||||
if (!text.trim() || isStreaming) return
|
||||
setHistory(h => [...h.slice(-49), text])
|
||||
setHistoryIdx(-1)
|
||||
setValue("")
|
||||
onSubmit(text)
|
||||
}, [onSubmit, isStreaming])
|
||||
|
||||
return (
|
||||
<Box paddingLeft={2} paddingRight={2} marginTop={1}>
|
||||
{isStreaming
|
||||
? <Text color={colors.muted}>~ generating… ^C to abort</Text>
|
||||
: <>
|
||||
<Text color={colors.orange}>{sym.prompt} </Text>
|
||||
<TextInput
|
||||
value={value}
|
||||
onChange={setValue}
|
||||
onSubmit={handleSubmit}
|
||||
placeholder="message…"
|
||||
placeholderColor={colors.subtle}
|
||||
/>
|
||||
</>
|
||||
}
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
import React from "react"
|
||||
import { Box, Text } from "ink"
|
||||
import { colors, sym } from "../theme/index.js"
|
||||
import type { RecalledMemory } from "@sage/memory"
|
||||
|
||||
interface Props {
|
||||
memories: RecalledMemory[]
|
||||
}
|
||||
|
||||
export function MemoryBar({ memories }: Props) {
|
||||
if (memories.length === 0) return null
|
||||
const preview = memories[0]?.content.slice(0, 55) ?? ""
|
||||
const more = memories.length > 1 ? ` +${memories.length - 1}` : ""
|
||||
return (
|
||||
<Box paddingLeft={2} marginTop={1}>
|
||||
<Text color={colors.yellow}>{sym.memory} </Text>
|
||||
<Text color={colors.muted}>{preview}{more}</Text>
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
import React, { useState, useEffect } from "react"
|
||||
import { Box, Text, useInput } from "ink"
|
||||
import { colors, sym } from "../theme/index.js"
|
||||
import { listMemories, deleteMemory, type Memory } from "@sage/memory"
|
||||
|
||||
interface Props { onClose: () => void }
|
||||
|
||||
export function MemoryPanel({ onClose }: Props) {
|
||||
const [mems, setMems] = useState<Memory[]>([])
|
||||
const [cursor, setCursor] = useState(0)
|
||||
|
||||
useEffect(() => { listMemories().then(setMems) }, [])
|
||||
|
||||
useInput((input, key) => {
|
||||
if (key.escape || input === "q") { onClose(); return }
|
||||
if (key.upArrow) { setCursor(c => Math.max(0, c - 1)); return }
|
||||
if (key.downArrow) { setCursor(c => Math.min(mems.length - 1, c + 1)); return }
|
||||
if (input === "d" && mems[cursor]) {
|
||||
deleteMemory(mems[cursor].id).then(() =>
|
||||
listMemories().then(m => { setMems(m); setCursor(c => Math.min(c, m.length - 1)) })
|
||||
)
|
||||
}
|
||||
})
|
||||
|
||||
return (
|
||||
<Box flexDirection="column" paddingLeft={2} paddingTop={1}>
|
||||
<Box marginBottom={1}>
|
||||
<Text color={colors.muted}>{sym.memory} memories </Text>
|
||||
<Text color={colors.subtle}>↑↓ navigate · d delete · esc close</Text>
|
||||
</Box>
|
||||
|
||||
{mems.length === 0 && (
|
||||
<Text color={colors.subtle}> no memories — use /remember <text></Text>
|
||||
)}
|
||||
|
||||
{mems.slice(0, 30).map((m, i) => {
|
||||
const active = i === cursor
|
||||
const text = m.content.length > 70 ? m.content.slice(0, 69) + "…" : m.content
|
||||
return (
|
||||
<Box key={m.id}>
|
||||
<Text color={active ? colors.yellow : colors.subtle}>
|
||||
{active ? `${sym.user} ` : " "}
|
||||
</Text>
|
||||
<Text color={active ? colors.text : colors.muted}>{text}</Text>
|
||||
</Box>
|
||||
)
|
||||
})}
|
||||
{mems.length > 30 && (
|
||||
<Text color={colors.subtle}> … {mems.length - 30} more</Text>
|
||||
)}
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,130 @@
|
||||
import React from "react"
|
||||
import { Box, Text } from "ink"
|
||||
import { colors, toolIcon, sym } from "../theme/index.js"
|
||||
import type { Message } from "@sage/core"
|
||||
import type { ActiveToolCall } from "../hooks/useAppState.js"
|
||||
|
||||
interface Props {
|
||||
messages: Message[]
|
||||
toolCalls: ActiveToolCall[]
|
||||
streamBuffer: string
|
||||
isStreaming: boolean
|
||||
}
|
||||
|
||||
export function MessageList({ messages, toolCalls, streamBuffer, isStreaming }: Props) {
|
||||
const display = messages.filter(m => m.role !== "tool")
|
||||
|
||||
return (
|
||||
<Box flexDirection="column" paddingLeft={2} paddingRight={2} flexGrow={1}>
|
||||
{display.map((msg, i) => (
|
||||
<MsgItem key={msg.id} msg={msg} first={i === 0} />
|
||||
))}
|
||||
|
||||
{/* In-flight tool calls */}
|
||||
{toolCalls.map(tc => (
|
||||
<ToolLine key={tc.id} tc={tc} />
|
||||
))}
|
||||
|
||||
{/* Streaming assistant text */}
|
||||
{isStreaming && streamBuffer && (
|
||||
<Box flexDirection="column" marginTop={1}>
|
||||
<Box>
|
||||
<Text color={colors.green}>{sym.assistant} </Text>
|
||||
<Text color={colors.text}>{streamBuffer}</Text>
|
||||
<Text color={colors.muted}>▊</Text>
|
||||
</Box>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{isStreaming && !streamBuffer && toolCalls.length === 0 && (
|
||||
<Box marginTop={1}>
|
||||
<Text color={colors.muted}>~ thinking…</Text>
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
|
||||
function MsgItem({ msg, first }: { msg: Message; first: boolean }) {
|
||||
if (msg.role === "user") {
|
||||
return (
|
||||
<Box flexDirection="column" marginTop={first ? 0 : 1}>
|
||||
{msg.content.split("\n").map((line, i) => (
|
||||
<Box key={i}>
|
||||
<Text color={colors.blue}>{sym.user} </Text>
|
||||
<Text color={colors.text} bold={i === 0}>{line}</Text>
|
||||
</Box>
|
||||
))}
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
|
||||
if (msg.role === "assistant") {
|
||||
const lines = msg.content.trim().split("\n")
|
||||
return (
|
||||
<Box flexDirection="column" marginTop={1}>
|
||||
{lines.map((line, i) => (
|
||||
<Box key={i}>
|
||||
{i === 0
|
||||
? <Text color={colors.green}>{sym.assistant} </Text>
|
||||
: <Text>{" "}</Text>
|
||||
}
|
||||
<Text color={colors.text}>{line}</Text>
|
||||
</Box>
|
||||
))}
|
||||
{msg.tokensOutput != null && (
|
||||
<Box marginTop={0}>
|
||||
<Text color={colors.subtle}>{" "}{sym.dot} {msg.tokensOutput} tokens</Text>
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
function ToolLine({ tc }: { tc: ActiveToolCall }) {
|
||||
const icon = toolIcon[tc.name] ?? toolIcon.default
|
||||
const isDone = tc.status === "done"
|
||||
const isErr = tc.status === "error"
|
||||
const color = isErr ? colors.red : isDone ? colors.muted : colors.orange
|
||||
|
||||
// Show most relevant input arg as a short label
|
||||
const label = getLabel(tc.name, tc.input)
|
||||
|
||||
// Output preview: first non-empty line, max 80 chars
|
||||
const preview = tc.output
|
||||
? tc.output.split("\n").find(l => l.trim()) ?? ""
|
||||
: ""
|
||||
const previewTrunc = preview.length > 80 ? preview.slice(0, 79) + "…" : preview
|
||||
|
||||
return (
|
||||
<Box flexDirection="column" marginTop={1}>
|
||||
<Box>
|
||||
<Text color={color}>
|
||||
{isDone ? sym.check : isErr ? sym.cross : sym.running}{" "}
|
||||
</Text>
|
||||
<Text color={colors.muted}>{icon} </Text>
|
||||
<Text color={isDone ? colors.muted : colors.text}>{label}</Text>
|
||||
</Box>
|
||||
{previewTrunc && (
|
||||
<Box paddingLeft={4}>
|
||||
<Text color={colors.subtle}>{previewTrunc}</Text>
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
|
||||
function getLabel(name: string, input: Record<string, unknown>): string {
|
||||
switch (name) {
|
||||
case "bash": return String(input.command ?? "").slice(0, 72)
|
||||
case "read": return String(input.file_path ?? "")
|
||||
case "write": return String(input.file_path ?? "")
|
||||
case "edit": return String(input.file_path ?? "")
|
||||
case "glob": return String(input.pattern ?? "")
|
||||
case "grep": return `${input.pattern ?? ""} ${input.path ?? ""}`.trim()
|
||||
default: return name
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
import React from "react"
|
||||
import { Box, Text, useInput } from "ink"
|
||||
import { colors, sym } from "../theme/index.js"
|
||||
import type { Session } from "@sage/core"
|
||||
|
||||
interface Props {
|
||||
sessions: Session[]
|
||||
cursor: number
|
||||
onSelect: (s: Session) => void
|
||||
onNew: () => void
|
||||
onDelete: (id: string) => void
|
||||
onClose: () => void
|
||||
setCursor: (n: number) => void
|
||||
}
|
||||
|
||||
export function SessionOverlay({ sessions, cursor, onSelect, onNew, onDelete, onClose, setCursor }: Props) {
|
||||
useInput((input, key) => {
|
||||
if (key.escape || input === "q") { onClose(); return }
|
||||
if (key.upArrow) { setCursor(Math.max(0, cursor - 1)); return }
|
||||
if (key.downArrow) { setCursor(Math.min(sessions.length - 1, cursor + 1)); return }
|
||||
if (key.return) { const s = sessions[cursor]; if (s) { onSelect(s); onClose() } return }
|
||||
if (input === "n") { onNew(); onClose(); return }
|
||||
if (input === "d") {
|
||||
const s = sessions[cursor]
|
||||
if (s) { onDelete(s.id); setCursor(Math.max(0, cursor - 1)) }
|
||||
return
|
||||
}
|
||||
})
|
||||
|
||||
return (
|
||||
<Box flexDirection="column" paddingLeft={2} paddingTop={1}>
|
||||
<Box marginBottom={1}>
|
||||
<Text color={colors.muted}>sessions </Text>
|
||||
<Text color={colors.subtle}>↑↓ navigate · enter select · n new · d delete · esc close</Text>
|
||||
</Box>
|
||||
|
||||
{sessions.length === 0 && (
|
||||
<Text color={colors.subtle}> no sessions yet — press n to start</Text>
|
||||
)}
|
||||
|
||||
{sessions.map((s, i) => {
|
||||
const active = i === cursor
|
||||
const title = s.title.length > 60 ? s.title.slice(0, 59) + "…" : s.title
|
||||
return (
|
||||
<Box key={s.id}>
|
||||
<Text color={active ? colors.blue : colors.subtle}>
|
||||
{active ? `${sym.user} ` : " "}
|
||||
</Text>
|
||||
<Text color={active ? colors.text : colors.muted}>{title}</Text>
|
||||
{active && (
|
||||
<Text color={colors.subtle}>
|
||||
{" "}{new Date(s.updatedAt).toLocaleDateString()}
|
||||
</Text>
|
||||
)}
|
||||
</Box>
|
||||
)
|
||||
})}
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
import React from "react"
|
||||
import { Box, Text, useStdout } from "ink"
|
||||
import { colors, symbols } from "../theme/index.js"
|
||||
import type { Session } from "@sage/core"
|
||||
|
||||
interface Props {
|
||||
sessions: Session[]
|
||||
current: Session | null
|
||||
onSelect: (s: Session) => void
|
||||
onNew: () => void
|
||||
}
|
||||
|
||||
export function SessionPanel({ sessions, current, onSelect, onNew }: Props) {
|
||||
const { stdout } = useStdout()
|
||||
|
||||
return (
|
||||
<Box flexDirection="column" borderStyle="single" borderColor={colors.border} width={28} paddingX={1}>
|
||||
<Box marginBottom={1}>
|
||||
<Text color={colors.purple} bold>◈ SAGE</Text>
|
||||
<Text color={colors.fgDim}> sessions</Text>
|
||||
</Box>
|
||||
|
||||
{sessions.length === 0 && (
|
||||
<Text color={colors.fgDim} italic> No sessions yet</Text>
|
||||
)}
|
||||
|
||||
{sessions.slice(0, 20).map((s, i) => {
|
||||
const isCurrent = s.id === current?.id
|
||||
const title = s.title.length > 20 ? s.title.slice(0, 19) + symbols.ellipsis : s.title
|
||||
return (
|
||||
<Box key={s.id} marginBottom={0}>
|
||||
<Text
|
||||
color={isCurrent ? colors.blue : colors.fgDim}
|
||||
bold={isCurrent}
|
||||
>
|
||||
{isCurrent ? "▶ " : " "}{title}
|
||||
</Text>
|
||||
</Box>
|
||||
)
|
||||
})}
|
||||
|
||||
<Box marginTop={1} borderStyle="single" borderColor={colors.fgMuted}>
|
||||
<Text color={colors.fgDim}> n new session</Text>
|
||||
</Box>
|
||||
<Box>
|
||||
<Text color={colors.fgDim}> ↑↓ navigate</Text>
|
||||
</Box>
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
import React from "react"
|
||||
import { Box, Text } from "ink"
|
||||
import { colors, symbols } from "../theme/index.js"
|
||||
|
||||
interface Props {
|
||||
model: string
|
||||
provider: string
|
||||
cwd: string
|
||||
isStreaming: boolean
|
||||
usage: { input: number; output: number }
|
||||
memoryCount: number
|
||||
}
|
||||
|
||||
export function StatusBar({ model, provider, cwd, isStreaming, usage, memoryCount }: Props) {
|
||||
const cwdShort = cwd.replace(/^.*[/\\]/, "")
|
||||
|
||||
return (
|
||||
<Box paddingX={1} justifyContent="space-between" borderStyle="single" borderColor={colors.fgMuted}>
|
||||
<Box gap={2}>
|
||||
<Text color={colors.purple}>◈ sage</Text>
|
||||
<Text color={colors.blue}>{provider}</Text>
|
||||
<Text color={colors.cyan}>{model}</Text>
|
||||
{isStreaming && <Text color={colors.yellow}>● streaming</Text>}
|
||||
</Box>
|
||||
<Box gap={2}>
|
||||
{memoryCount > 0 && (
|
||||
<Text color={colors.orange}>{symbols.memory}{memoryCount}</Text>
|
||||
)}
|
||||
{(usage.input > 0 || usage.output > 0) && (
|
||||
<Text color={colors.fgDim}>↑{usage.input} ↓{usage.output}</Text>
|
||||
)}
|
||||
<Text color={colors.fgDim}>{cwdShort}</Text>
|
||||
</Box>
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,252 @@
|
||||
import React, { useState, useEffect, useCallback } from "react"
|
||||
import type { Session, Message } from "@sage/core"
|
||||
import {
|
||||
listSessions, createSession, getMessages, addMessage,
|
||||
deleteSession, updateSessionTitle, loadConfig, getDb,
|
||||
} from "@sage/core"
|
||||
import type { CoreMessage } from "ai"
|
||||
import {
|
||||
recallMemories, formatMemoriesForContext, addMemory,
|
||||
summarizeSession, type RecalledMemory,
|
||||
} from "@sage/memory"
|
||||
import { runLLM, getLanguageModel, type ProviderID } from "@sage/llm"
|
||||
import { generateText } from "ai"
|
||||
|
||||
export interface ActiveToolCall {
|
||||
id: string
|
||||
name: string
|
||||
input: Record<string, unknown>
|
||||
output?: string
|
||||
status: "running" | "done" | "error"
|
||||
}
|
||||
|
||||
export interface AppState {
|
||||
sessions: Session[]
|
||||
currentSession: Session | null
|
||||
messages: Message[]
|
||||
toolCalls: ActiveToolCall[]
|
||||
isStreaming: boolean
|
||||
streamBuffer: string
|
||||
recalledMemories: RecalledMemory[]
|
||||
error: string | null
|
||||
usage: { input: number; output: number }
|
||||
abortController: AbortController | null
|
||||
openOverlay: "sessions" | "memories" | null
|
||||
}
|
||||
|
||||
export function useAppState() {
|
||||
const config = loadConfig()
|
||||
const [state, setState] = useState<AppState>({
|
||||
sessions: [],
|
||||
currentSession: null,
|
||||
messages: [],
|
||||
toolCalls: [],
|
||||
isStreaming: false,
|
||||
streamBuffer: "",
|
||||
recalledMemories: [],
|
||||
error: null,
|
||||
usage: { input: 0, output: 0 },
|
||||
abortController: null,
|
||||
openOverlay: null,
|
||||
})
|
||||
|
||||
const patch = useCallback(
|
||||
(p: Partial<AppState> | ((prev: AppState) => Partial<AppState>)) =>
|
||||
setState(s => ({ ...s, ...(typeof p === "function" ? p(s) : p) })),
|
||||
[]
|
||||
)
|
||||
|
||||
useEffect(() => { loadSessions() }, [])
|
||||
|
||||
async function loadSessions() {
|
||||
const sessions = await listSessions()
|
||||
if (sessions.length > 0) {
|
||||
const msgs = await getMessages(sessions[0].id)
|
||||
setState(s => ({ ...s, sessions, currentSession: sessions[0], messages: msgs }))
|
||||
} else {
|
||||
setState(s => ({ ...s, sessions }))
|
||||
}
|
||||
}
|
||||
|
||||
async function selectSession(session: Session) {
|
||||
const messages = await getMessages(session.id)
|
||||
patch({ currentSession: session, messages, toolCalls: [], streamBuffer: "", recalledMemories: [], error: null })
|
||||
}
|
||||
|
||||
async function newSession() {
|
||||
const session = await createSession({ model: config.defaultModel, provider: config.defaultProvider })
|
||||
const sessions = await listSessions()
|
||||
patch({ sessions, currentSession: session, messages: [], toolCalls: [], recalledMemories: [], error: null })
|
||||
}
|
||||
|
||||
async function removeSession(id: string) {
|
||||
await deleteSession(id)
|
||||
const sessions = await listSessions()
|
||||
if (sessions.length > 0) {
|
||||
const messages = await getMessages(sessions[0].id)
|
||||
patch({ sessions, currentSession: sessions[0], messages })
|
||||
} else {
|
||||
patch({ sessions, currentSession: null, messages: [] })
|
||||
}
|
||||
}
|
||||
|
||||
async function submitMessage(text: string) {
|
||||
if (!text.trim() || state.isStreaming) return
|
||||
if (text.startsWith("/")) { await handleCommand(text); return }
|
||||
|
||||
let session = state.currentSession
|
||||
if (!session) {
|
||||
session = await createSession({ model: config.defaultModel, provider: config.defaultProvider, title: text.slice(0, 50) })
|
||||
const sessions = await listSessions()
|
||||
patch({ sessions, currentSession: session })
|
||||
}
|
||||
|
||||
const recalled = await recallMemories(text, config.memory.recallCount)
|
||||
const memoryCtx = formatMemoriesForContext(recalled)
|
||||
const userMsg = await addMessage({ sessionId: session.id, role: "user", content: text })
|
||||
|
||||
// Auto-title on first message
|
||||
if (state.messages.length === 0 && session.title === "New Session") {
|
||||
const t = text.slice(0, 60).replace(/\n/g, " ")
|
||||
await updateSessionTitle(session.id, t)
|
||||
const sessions = await listSessions()
|
||||
patch({ sessions, currentSession: { ...session, title: t } })
|
||||
session = { ...session, title: t }
|
||||
}
|
||||
|
||||
const allMessages = [...state.messages, userMsg]
|
||||
patch({ messages: allMessages, recalledMemories: recalled, isStreaming: true, streamBuffer: "", toolCalls: [], error: null })
|
||||
|
||||
const coreMessages = buildCoreMessages(allMessages)
|
||||
const ac = new AbortController()
|
||||
patch({ abortController: ac })
|
||||
|
||||
let localToolCalls: ActiveToolCall[] = []
|
||||
|
||||
try {
|
||||
await runLLM({
|
||||
sessionId: session.id,
|
||||
messages: coreMessages,
|
||||
model: session.model,
|
||||
provider: session.provider,
|
||||
cwd: session.cwd,
|
||||
config,
|
||||
memoryContext: memoryCtx || undefined,
|
||||
signal: ac.signal,
|
||||
onEvent: (event) => {
|
||||
switch (event.type) {
|
||||
case "text-delta":
|
||||
patch(s => ({ streamBuffer: s.streamBuffer + (event.delta ?? "") }))
|
||||
break
|
||||
case "tool-call": {
|
||||
const tc: ActiveToolCall = { id: event.toolCall!.id, name: event.toolCall!.name, input: event.toolCall!.input, status: "running" }
|
||||
localToolCalls = [...localToolCalls, tc]
|
||||
patch({ toolCalls: localToolCalls })
|
||||
break
|
||||
}
|
||||
case "tool-result": {
|
||||
localToolCalls = localToolCalls.map(tc =>
|
||||
tc.id === event.toolResult?.toolCallId
|
||||
? { ...tc, output: event.toolResult!.output, status: "done" as const }
|
||||
: tc
|
||||
)
|
||||
patch({ toolCalls: localToolCalls })
|
||||
break
|
||||
}
|
||||
case "finish":
|
||||
if (event.usage) patch(s => ({ usage: { input: s.usage.input + event.usage!.inputTokens, output: s.usage.output + event.usage!.outputTokens } }))
|
||||
break
|
||||
case "error":
|
||||
patch({ error: event.error?.message ?? "Unknown error" })
|
||||
break
|
||||
}
|
||||
},
|
||||
})
|
||||
} catch (err: unknown) {
|
||||
if ((err as Error)?.name !== "AbortError") patch({ error: (err as Error)?.message ?? "Request failed" })
|
||||
}
|
||||
|
||||
const updated = await getMessages(session.id)
|
||||
patch({ messages: updated, isStreaming: false, streamBuffer: "", abortController: null })
|
||||
|
||||
if (config.memory.autoSummarize && updated.length > 0 && updated.length % 20 === 0) {
|
||||
autoSummarize(session, updated)
|
||||
}
|
||||
}
|
||||
|
||||
async function autoSummarize(session: Session, msgs: Message[]) {
|
||||
try {
|
||||
const lm = getLanguageModel(session.provider as ProviderID, session.model, config)
|
||||
await summarizeSession(session.id, buildCoreMessages(msgs), async (prompt) => {
|
||||
const r = await generateText({ model: lm, prompt, maxTokens: 800 })
|
||||
return r.text
|
||||
})
|
||||
} catch { /* non-critical */ }
|
||||
}
|
||||
|
||||
async function handleCommand(cmd: string) {
|
||||
const parts = cmd.trim().split(/\s+/)
|
||||
const command = parts[0]?.toLowerCase()
|
||||
switch (command) {
|
||||
case "/remember": {
|
||||
const content = parts.slice(1).join(" ")
|
||||
if (!content) { patch({ error: "usage: /remember <text>" }); break }
|
||||
await addMemory(content, { source: "manual" })
|
||||
patch({ error: "✓ memory saved" })
|
||||
setTimeout(() => patch({ error: null }), 2000)
|
||||
break
|
||||
}
|
||||
case "/memories": patch({ openOverlay: "memories" }); break
|
||||
case "/sessions": patch({ openOverlay: "sessions" }); break
|
||||
case "/new": await newSession(); break
|
||||
case "/clear": patch({ messages: [], toolCalls: [], streamBuffer: "" }); break
|
||||
case "/abort": abort(); break
|
||||
case "/model": {
|
||||
const model = parts[1]
|
||||
const provider = parts[2] ?? state.currentSession?.provider ?? config.defaultProvider
|
||||
if (!model) { patch({ error: "usage: /model <id> [provider]" }); break }
|
||||
if (state.currentSession) {
|
||||
patch({ currentSession: { ...state.currentSession, model, provider }, error: `✓ switched to ${provider}/${model}` })
|
||||
setTimeout(() => patch({ error: null }), 2000)
|
||||
}
|
||||
break
|
||||
}
|
||||
case "/help":
|
||||
patch({ error: "/remember <t> /memories /sessions /model <id> [prov] /new /clear /abort ^K sessions" })
|
||||
break
|
||||
default:
|
||||
patch({ error: `unknown command: ${command} — /help for list` })
|
||||
}
|
||||
}
|
||||
|
||||
function abort() {
|
||||
state.abortController?.abort()
|
||||
patch({ isStreaming: false, abortController: null })
|
||||
}
|
||||
|
||||
return { state, patch, submitMessage, selectSession, newSession, removeSession, abort, config }
|
||||
}
|
||||
|
||||
function buildCoreMessages(msgs: Message[]): CoreMessage[] {
|
||||
const result: CoreMessage[] = []
|
||||
for (const m of msgs) {
|
||||
if (m.role === "user") {
|
||||
result.push({ role: "user", content: m.content })
|
||||
} else if (m.role === "assistant") {
|
||||
if (m.toolCalls?.length) {
|
||||
result.push({
|
||||
role: "assistant",
|
||||
content: [
|
||||
...(m.content ? [{ type: "text" as const, text: m.content }] : []),
|
||||
...m.toolCalls.map(tc => ({ type: "tool-call" as const, toolCallId: tc.id, toolName: tc.name, args: tc.input })),
|
||||
],
|
||||
})
|
||||
} else {
|
||||
result.push({ role: "assistant", content: m.content })
|
||||
}
|
||||
} else if (m.role === "tool") {
|
||||
result.push({ role: "tool", content: [{ type: "tool-result" as const, toolCallId: m.toolCallId ?? "", result: m.content }] })
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
#!/usr/bin/env node
|
||||
import React from "react"
|
||||
import { render } from "ink"
|
||||
import { App } from "./app.js"
|
||||
import { getDb, loadConfig } from "@sage/core"
|
||||
|
||||
getDb()
|
||||
loadConfig()
|
||||
|
||||
const { unmount } = render(<App />, { exitOnCtrlC: false })
|
||||
|
||||
process.on("SIGINT", () => { unmount(); process.exit(0) })
|
||||
process.on("SIGTERM", () => { unmount(); process.exit(0) })
|
||||
@@ -0,0 +1,42 @@
|
||||
// Opencode-inspired minimal palette
|
||||
export const colors = {
|
||||
bg: "#0a0a0a",
|
||||
panel: "#141414",
|
||||
element: "#1e1e1e",
|
||||
|
||||
text: "#eeeeee",
|
||||
muted: "#808080",
|
||||
subtle: "#484848",
|
||||
focus: "#606060",
|
||||
|
||||
blue: "#7aa2f7",
|
||||
green: "#7fd88f",
|
||||
orange: "#fab283",
|
||||
purple: "#9d7cd8",
|
||||
cyan: "#56b6c2",
|
||||
red: "#f7768e",
|
||||
yellow: "#e0af68",
|
||||
} as const
|
||||
|
||||
// Single-char tool icons (2-char wide with trailing space)
|
||||
export const toolIcon: Record<string, string> = {
|
||||
bash: "$",
|
||||
read: "→",
|
||||
write: "←",
|
||||
edit: "←",
|
||||
glob: "✱",
|
||||
grep: "✱",
|
||||
default: "·",
|
||||
}
|
||||
|
||||
export const sym = {
|
||||
user: "▌", // left-border marker for user messages
|
||||
assistant: "◆",
|
||||
memory: "◊",
|
||||
check: "✓",
|
||||
cross: "✗",
|
||||
running: "~",
|
||||
prompt: "❯",
|
||||
dot: "·",
|
||||
bar: "─",
|
||||
} as const
|
||||
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"extends": "../../tsconfig.json",
|
||||
"compilerOptions": {
|
||||
"outDir": "dist",
|
||||
"rootDir": "src"
|
||||
},
|
||||
"include": ["src"]
|
||||
}
|
||||
Reference in New Issue
Block a user