camelagi 0.5.49 → 0.5.51
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/cli/cmd-chat.js +69 -2
- package/dist/cli/cmd-chat.js.map +1 -1
- package/dist/core/version.js +1 -1
- package/dist/runtime/orchestrate.js +1 -1
- package/dist/telegram/admin-bot.js +1 -1
- package/package.json +5 -2
- package/tui/package.json +23 -0
- package/tui/src/App.tsx +161 -0
- package/tui/src/agent/parse.ts +184 -0
- package/tui/src/agent/types.ts +75 -0
- package/tui/src/commands/registry.ts +301 -0
- package/tui/src/components/ActivityIndicator.tsx +148 -0
- package/tui/src/components/ApprovalPrompt.tsx +58 -0
- package/tui/src/components/BottomBar.tsx +74 -0
- package/tui/src/components/Chat.tsx +98 -0
- package/tui/src/components/Divider.tsx +12 -0
- package/tui/src/components/HorizontalRule.tsx +12 -0
- package/tui/src/components/Input.tsx +126 -0
- package/tui/src/components/Markdown.tsx +290 -0
- package/tui/src/components/Message.tsx +77 -0
- package/tui/src/components/PermissionBanner.tsx +30 -0
- package/tui/src/components/Picker.tsx +127 -0
- package/tui/src/components/SlashMenu.tsx +46 -0
- package/tui/src/components/SubagentBlock.tsx +16 -0
- package/tui/src/components/ToolBlock.tsx +24 -0
- package/tui/src/components/Welcome.tsx +75 -0
- package/tui/src/components/tools/BashTool.tsx +27 -0
- package/tui/src/components/tools/DefaultTool.tsx +38 -0
- package/tui/src/components/tools/DiffView.tsx +91 -0
- package/tui/src/components/tools/EditGroup.tsx +97 -0
- package/tui/src/components/tools/EditTool.tsx +41 -0
- package/tui/src/components/tools/ReadTool.tsx +41 -0
- package/tui/src/components/tools/SearchTool.tsx +27 -0
- package/tui/src/components/tools/ToolHeader.tsx +48 -0
- package/tui/src/components/tools/WriteTool.tsx +54 -0
- package/tui/src/config.ts +6 -0
- package/tui/src/hooks/useAgent.ts +202 -0
- package/tui/src/main.tsx +12 -0
- package/tui/src/models.ts +26 -0
- package/tui/src/state/reducer.ts +290 -0
- package/tui/src/theme.ts +28 -0
- package/tui/src/util/nativeNotify.ts +47 -0
- package/tui/src/util/spinner.ts +11 -0
- package/tui/tsconfig.json +19 -0
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import { useTerminalDimensions } from "@opentui/react"
|
|
2
|
+
import { theme } from "../theme.js"
|
|
3
|
+
|
|
4
|
+
export function HorizontalRule({ marginTop = 0, marginBottom = 0 }: { marginTop?: number; marginBottom?: number }) {
|
|
5
|
+
const { width } = useTerminalDimensions()
|
|
6
|
+
const w = Math.max(20, Math.min(width, 400))
|
|
7
|
+
return (
|
|
8
|
+
<box marginTop={marginTop} marginBottom={marginBottom}>
|
|
9
|
+
<text content={"─".repeat(w)} fg={theme.divider} />
|
|
10
|
+
</box>
|
|
11
|
+
)
|
|
12
|
+
}
|
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
// Plain text input row. No border (Claude Code style: dividers above/below
|
|
2
|
+
// supplied by the parent). Owns its own buffer; emits onSubmit on Enter.
|
|
3
|
+
// Slash-mode bookkeeping is exposed via onSlashStateChange so the parent
|
|
4
|
+
// can swap the bottom area between BottomBar and SlashMenu.
|
|
5
|
+
|
|
6
|
+
import { useEffect, useMemo, useState } from "react"
|
|
7
|
+
import { useKeyboard } from "@opentui/react"
|
|
8
|
+
import { fg, t, bold } from "@opentui/core"
|
|
9
|
+
import { filterCommands, type SlashCommand } from "../commands/registry.js"
|
|
10
|
+
import { theme } from "../theme.js"
|
|
11
|
+
|
|
12
|
+
export interface InputProps {
|
|
13
|
+
disabled?: boolean
|
|
14
|
+
onSubmit: (text: string) => void
|
|
15
|
+
onSlash: (commandName: string, args: string[]) => void
|
|
16
|
+
onAbort?: () => void
|
|
17
|
+
onCyclePermission?: () => void
|
|
18
|
+
/** Notifies parent of current slash-mode state so it can render the menu in the bottom area. */
|
|
19
|
+
onSlashState?: (state: { matches: SlashCommand[]; selectedIndex: number; argMode?: boolean } | null) => void
|
|
20
|
+
placeholder?: string
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export function Input({
|
|
24
|
+
disabled,
|
|
25
|
+
onSubmit,
|
|
26
|
+
onSlash,
|
|
27
|
+
onAbort,
|
|
28
|
+
onCyclePermission,
|
|
29
|
+
onSlashState,
|
|
30
|
+
placeholder,
|
|
31
|
+
}: InputProps) {
|
|
32
|
+
const [value, setValue] = useState("")
|
|
33
|
+
const [menuIdx, setMenuIdx] = useState(0)
|
|
34
|
+
|
|
35
|
+
const inSlashMode = value.startsWith("/")
|
|
36
|
+
const slashTokens = inSlashMode ? value.slice(1).split(/\s+/) : []
|
|
37
|
+
const slashQuery = inSlashMode ? slashTokens[0] ?? "" : ""
|
|
38
|
+
|
|
39
|
+
const matches = useMemo<SlashCommand[]>(() => {
|
|
40
|
+
if (!inSlashMode) return []
|
|
41
|
+
if (slashTokens.length === 1) return filterCommands(slashQuery)
|
|
42
|
+
return []
|
|
43
|
+
}, [inSlashMode, slashQuery, slashTokens.length])
|
|
44
|
+
|
|
45
|
+
// Keep menuIdx in range as matches change.
|
|
46
|
+
if (menuIdx >= matches.length && matches.length > 0) {
|
|
47
|
+
setMenuIdx(0)
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
// Notify parent.
|
|
51
|
+
useEffect(() => {
|
|
52
|
+
if (matches.length > 0) onSlashState?.({ matches, selectedIndex: menuIdx })
|
|
53
|
+
else onSlashState?.(null)
|
|
54
|
+
}, [matches, menuIdx, onSlashState])
|
|
55
|
+
|
|
56
|
+
useKeyboard(key => {
|
|
57
|
+
if (key.shift && key.name === "tab") {
|
|
58
|
+
onCyclePermission?.()
|
|
59
|
+
return
|
|
60
|
+
}
|
|
61
|
+
if (disabled) {
|
|
62
|
+
if (key.name === "escape") onAbort?.()
|
|
63
|
+
return
|
|
64
|
+
}
|
|
65
|
+
if (matches.length > 0) {
|
|
66
|
+
if (key.name === "up") { setMenuIdx(i => Math.max(0, i - 1)); return }
|
|
67
|
+
if (key.name === "down") { setMenuIdx(i => Math.min(matches.length - 1, i + 1)); return }
|
|
68
|
+
if (key.name === "tab") {
|
|
69
|
+
const pick = matches[menuIdx]
|
|
70
|
+
if (pick) setValue(`/${pick.name} `)
|
|
71
|
+
return
|
|
72
|
+
}
|
|
73
|
+
if (key.name === "return") {
|
|
74
|
+
const pick = matches[menuIdx]
|
|
75
|
+
if (pick) {
|
|
76
|
+
setValue("")
|
|
77
|
+
setMenuIdx(0)
|
|
78
|
+
onSlash(pick.name, [])
|
|
79
|
+
}
|
|
80
|
+
return
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
if (key.name === "return") {
|
|
84
|
+
const trimmed = value.trim()
|
|
85
|
+
setValue("")
|
|
86
|
+
setMenuIdx(0)
|
|
87
|
+
if (!trimmed) return
|
|
88
|
+
if (trimmed.startsWith("/")) {
|
|
89
|
+
const tokens = trimmed.slice(1).split(/\s+/)
|
|
90
|
+
const [name, ...args] = tokens
|
|
91
|
+
onSlash(name, args)
|
|
92
|
+
return
|
|
93
|
+
}
|
|
94
|
+
onSubmit(trimmed)
|
|
95
|
+
return
|
|
96
|
+
}
|
|
97
|
+
if (key.name === "escape") {
|
|
98
|
+
if (value) setValue("")
|
|
99
|
+
else onAbort?.()
|
|
100
|
+
return
|
|
101
|
+
}
|
|
102
|
+
if (key.name === "backspace") {
|
|
103
|
+
setValue(v => v.slice(0, -1))
|
|
104
|
+
return
|
|
105
|
+
}
|
|
106
|
+
if (typeof key.sequence === "string" && key.sequence.length === 1) {
|
|
107
|
+
const ch = key.sequence
|
|
108
|
+
const code = ch.charCodeAt(0)
|
|
109
|
+
if (code >= 32 && code !== 127) setValue(v => v + ch)
|
|
110
|
+
}
|
|
111
|
+
})
|
|
112
|
+
|
|
113
|
+
// Bigger, bolder caret + chunkier block cursor for visibility.
|
|
114
|
+
const cursor = !disabled ? bold(fg(theme.assistant)("█")) : ""
|
|
115
|
+
const prompt = bold(fg(theme.assistant)("❯ "))
|
|
116
|
+
const showPlaceholder = !value && placeholder && !disabled
|
|
117
|
+
const promptContent = showPlaceholder
|
|
118
|
+
? t`${prompt}${fg(theme.dim)(placeholder)}${cursor}`
|
|
119
|
+
: t`${prompt}${fg(theme.assistant)(value)}${cursor}`
|
|
120
|
+
|
|
121
|
+
return (
|
|
122
|
+
<box paddingLeft={1} paddingRight={1} marginTop={1} marginBottom={1}>
|
|
123
|
+
<text content={promptContent} />
|
|
124
|
+
</box>
|
|
125
|
+
)
|
|
126
|
+
}
|
|
@@ -0,0 +1,290 @@
|
|
|
1
|
+
// Lightweight markdown renderer for assistant text. Handles the patterns
|
|
2
|
+
// LLMs actually emit:
|
|
3
|
+
// - **bold** *italic* `inline code`
|
|
4
|
+
// - ``` fenced code blocks ```
|
|
5
|
+
// - # heading ## heading
|
|
6
|
+
// - "- " / "* " bulleted lists
|
|
7
|
+
// - "1. " numbered lists (cyan number, Codex style)
|
|
8
|
+
// - > blockquote
|
|
9
|
+
//
|
|
10
|
+
// Output is OpenTUI <text>/<box> elements. Inline styling builds StyledText
|
|
11
|
+
// directly from TextChunks (the class OpenTUI's <text content> consumes).
|
|
12
|
+
|
|
13
|
+
import { Fragment, type ReactNode } from "react"
|
|
14
|
+
import { fg, bg, bold, italic, t, StyledText, type TextChunk } from "@opentui/core"
|
|
15
|
+
import { theme } from "../theme.js"
|
|
16
|
+
|
|
17
|
+
export function Markdown({ text, prefix }: { text: string; prefix?: TextChunk }) {
|
|
18
|
+
const blocks = parseBlocks(text)
|
|
19
|
+
// Drop the prefix unless the very first block is a paragraph — bullets
|
|
20
|
+
// glued to a code block or heading look noisy.
|
|
21
|
+
const firstIsParagraph = blocks.length > 0 && blocks[0].kind === "paragraph"
|
|
22
|
+
return (
|
|
23
|
+
<Fragment>
|
|
24
|
+
{blocks.map((b, i) =>
|
|
25
|
+
renderBlock(b, i, i === 0 && firstIsParagraph ? prefix : undefined),
|
|
26
|
+
)}
|
|
27
|
+
</Fragment>
|
|
28
|
+
)
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
// ── block-level parser ─────────────────────────────────────────────
|
|
32
|
+
|
|
33
|
+
type Block =
|
|
34
|
+
| { kind: "paragraph"; lines: string[] }
|
|
35
|
+
| { kind: "code"; lang: string; content: string }
|
|
36
|
+
| { kind: "heading"; level: 1 | 2 | 3; text: string }
|
|
37
|
+
| { kind: "bullet"; items: string[] }
|
|
38
|
+
| { kind: "numbered"; items: string[] }
|
|
39
|
+
| { kind: "quote"; lines: string[] }
|
|
40
|
+
|
|
41
|
+
function parseBlocks(text: string): Block[] {
|
|
42
|
+
const blocks: Block[] = []
|
|
43
|
+
const rawLines = text.split("\n")
|
|
44
|
+
let i = 0
|
|
45
|
+
|
|
46
|
+
while (i < rawLines.length) {
|
|
47
|
+
const line = rawLines[i]
|
|
48
|
+
|
|
49
|
+
const fenceMatch = line.match(/^```(\w*)/)
|
|
50
|
+
if (fenceMatch) {
|
|
51
|
+
const lang = fenceMatch[1] ?? ""
|
|
52
|
+
const buf: string[] = []
|
|
53
|
+
i++
|
|
54
|
+
while (i < rawLines.length && !rawLines[i].startsWith("```")) {
|
|
55
|
+
buf.push(rawLines[i])
|
|
56
|
+
i++
|
|
57
|
+
}
|
|
58
|
+
i++ // skip closing fence
|
|
59
|
+
blocks.push({ kind: "code", lang, content: buf.join("\n") })
|
|
60
|
+
continue
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
const h = line.match(/^(#{1,3})\s+(.*)$/)
|
|
64
|
+
if (h) {
|
|
65
|
+
blocks.push({ kind: "heading", level: h[1].length as 1 | 2 | 3, text: h[2] })
|
|
66
|
+
i++
|
|
67
|
+
continue
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
if (line.match(/^[-*]\s/)) {
|
|
71
|
+
const items: string[] = []
|
|
72
|
+
while (i < rawLines.length && rawLines[i].match(/^[-*]\s/)) {
|
|
73
|
+
items.push(rawLines[i].replace(/^[-*]\s/, ""))
|
|
74
|
+
i++
|
|
75
|
+
}
|
|
76
|
+
blocks.push({ kind: "bullet", items })
|
|
77
|
+
continue
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
if (line.match(/^\d+\.\s/)) {
|
|
81
|
+
const items: string[] = []
|
|
82
|
+
while (i < rawLines.length && rawLines[i].match(/^\d+\.\s/)) {
|
|
83
|
+
items.push(rawLines[i].replace(/^\d+\.\s/, ""))
|
|
84
|
+
i++
|
|
85
|
+
}
|
|
86
|
+
blocks.push({ kind: "numbered", items })
|
|
87
|
+
continue
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
if (line.startsWith("> ")) {
|
|
91
|
+
const buf: string[] = []
|
|
92
|
+
while (i < rawLines.length && rawLines[i].startsWith("> ")) {
|
|
93
|
+
buf.push(rawLines[i].slice(2))
|
|
94
|
+
i++
|
|
95
|
+
}
|
|
96
|
+
blocks.push({ kind: "quote", lines: buf })
|
|
97
|
+
continue
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
if (line.trim() === "") {
|
|
101
|
+
i++
|
|
102
|
+
continue
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
const paraLines: string[] = []
|
|
106
|
+
while (
|
|
107
|
+
i < rawLines.length
|
|
108
|
+
&& rawLines[i].trim() !== ""
|
|
109
|
+
&& !rawLines[i].match(/^```/)
|
|
110
|
+
&& !rawLines[i].match(/^#{1,3}\s/)
|
|
111
|
+
&& !rawLines[i].match(/^[-*]\s/)
|
|
112
|
+
&& !rawLines[i].match(/^\d+\.\s/)
|
|
113
|
+
&& !rawLines[i].startsWith("> ")
|
|
114
|
+
) {
|
|
115
|
+
paraLines.push(rawLines[i])
|
|
116
|
+
i++
|
|
117
|
+
}
|
|
118
|
+
if (paraLines.length > 0) blocks.push({ kind: "paragraph", lines: paraLines })
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
return blocks
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
// ── block renderer ─────────────────────────────────────────────────
|
|
125
|
+
|
|
126
|
+
function renderBlock(block: Block, key: number, prefix?: TextChunk): ReactNode {
|
|
127
|
+
switch (block.kind) {
|
|
128
|
+
case "paragraph":
|
|
129
|
+
return (
|
|
130
|
+
<Fragment key={key}>
|
|
131
|
+
{block.lines.map((line, i) => {
|
|
132
|
+
const chunks = inlineChunks(line, theme.assistant)
|
|
133
|
+
const withPrefix = i === 0 && prefix ? [prefix, ...chunks] : chunks
|
|
134
|
+
return <text key={i} content={styled(withPrefix)} />
|
|
135
|
+
})}
|
|
136
|
+
</Fragment>
|
|
137
|
+
)
|
|
138
|
+
|
|
139
|
+
case "code":
|
|
140
|
+
return (
|
|
141
|
+
<box key={key} flexDirection="column" marginTop={1} marginBottom={1}>
|
|
142
|
+
{block.lang ? (
|
|
143
|
+
<text content={" " + block.lang} fg={theme.dim} />
|
|
144
|
+
) : null}
|
|
145
|
+
{block.content.split("\n").map((line, i) => (
|
|
146
|
+
<text
|
|
147
|
+
key={i}
|
|
148
|
+
content={t`${fg(theme.branch)("│ ")}${fg(theme.assistant)(line.length > 0 ? line : " ")}`}
|
|
149
|
+
/>
|
|
150
|
+
))}
|
|
151
|
+
</box>
|
|
152
|
+
)
|
|
153
|
+
|
|
154
|
+
case "heading":
|
|
155
|
+
return (
|
|
156
|
+
<box key={key} marginTop={1}>
|
|
157
|
+
<text content={styled([applyBold(applyFg(block.text, theme.assistant))])} />
|
|
158
|
+
</box>
|
|
159
|
+
)
|
|
160
|
+
|
|
161
|
+
case "bullet":
|
|
162
|
+
return (
|
|
163
|
+
<Fragment key={key}>
|
|
164
|
+
{block.items.map((item, i) => (
|
|
165
|
+
<text
|
|
166
|
+
key={i}
|
|
167
|
+
content={styled([
|
|
168
|
+
applyFg(" • ", theme.bullet),
|
|
169
|
+
...inlineChunks(item, theme.assistant),
|
|
170
|
+
])}
|
|
171
|
+
/>
|
|
172
|
+
))}
|
|
173
|
+
</Fragment>
|
|
174
|
+
)
|
|
175
|
+
|
|
176
|
+
case "numbered":
|
|
177
|
+
return (
|
|
178
|
+
<Fragment key={key}>
|
|
179
|
+
{block.items.map((item, i) => (
|
|
180
|
+
<text
|
|
181
|
+
key={i}
|
|
182
|
+
content={styled([
|
|
183
|
+
applyFg(` ${i + 1}. `, theme.number),
|
|
184
|
+
...inlineChunks(item, theme.assistant),
|
|
185
|
+
])}
|
|
186
|
+
/>
|
|
187
|
+
))}
|
|
188
|
+
</Fragment>
|
|
189
|
+
)
|
|
190
|
+
|
|
191
|
+
case "quote":
|
|
192
|
+
return (
|
|
193
|
+
<Fragment key={key}>
|
|
194
|
+
{block.lines.map((line, i) => (
|
|
195
|
+
<text
|
|
196
|
+
key={i}
|
|
197
|
+
content={styled([
|
|
198
|
+
applyFg("│ ", theme.dim),
|
|
199
|
+
...inlineChunks(line, theme.dim),
|
|
200
|
+
])}
|
|
201
|
+
/>
|
|
202
|
+
))}
|
|
203
|
+
</Fragment>
|
|
204
|
+
)
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
// ── inline parser & styling helpers ────────────────────────────────
|
|
209
|
+
|
|
210
|
+
function renderInline(line: string, defaultColor: string): StyledText {
|
|
211
|
+
return styled(inlineChunks(line, defaultColor))
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
function inlineChunks(line: string, defaultColor: string): TextChunk[] {
|
|
215
|
+
const tokens = tokenizeInline(line)
|
|
216
|
+
return tokens.map(tok => styleToken(tok, defaultColor))
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
type Token =
|
|
220
|
+
| { kind: "text"; v: string }
|
|
221
|
+
| { kind: "bold"; v: string }
|
|
222
|
+
| { kind: "italic"; v: string }
|
|
223
|
+
| { kind: "code"; v: string }
|
|
224
|
+
|
|
225
|
+
function tokenizeInline(line: string): Token[] {
|
|
226
|
+
const out: Token[] = []
|
|
227
|
+
let i = 0
|
|
228
|
+
while (i < line.length) {
|
|
229
|
+
if (line[i] === "`") {
|
|
230
|
+
const end = line.indexOf("`", i + 1)
|
|
231
|
+
if (end > i) {
|
|
232
|
+
out.push({ kind: "code", v: line.slice(i + 1, end) })
|
|
233
|
+
i = end + 1
|
|
234
|
+
continue
|
|
235
|
+
}
|
|
236
|
+
}
|
|
237
|
+
if (line[i] === "*" && line[i + 1] === "*") {
|
|
238
|
+
const end = line.indexOf("**", i + 2)
|
|
239
|
+
if (end > i + 1) {
|
|
240
|
+
out.push({ kind: "bold", v: line.slice(i + 2, end) })
|
|
241
|
+
i = end + 2
|
|
242
|
+
continue
|
|
243
|
+
}
|
|
244
|
+
}
|
|
245
|
+
if ((line[i] === "*" || line[i] === "_") && line[i + 1] && line[i + 1] !== " ") {
|
|
246
|
+
const ch = line[i]
|
|
247
|
+
const end = line.indexOf(ch, i + 1)
|
|
248
|
+
if (end > i + 1 && line[end - 1] !== " ") {
|
|
249
|
+
out.push({ kind: "italic", v: line.slice(i + 1, end) })
|
|
250
|
+
i = end + 1
|
|
251
|
+
continue
|
|
252
|
+
}
|
|
253
|
+
}
|
|
254
|
+
let j = i
|
|
255
|
+
while (j < line.length && line[j] !== "`" && line[j] !== "*" && line[j] !== "_") j++
|
|
256
|
+
if (j === i) j = i + 1
|
|
257
|
+
out.push({ kind: "text", v: line.slice(i, j) })
|
|
258
|
+
i = j
|
|
259
|
+
}
|
|
260
|
+
return out
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
function styleToken(tok: Token, defaultColor: string): TextChunk {
|
|
264
|
+
switch (tok.kind) {
|
|
265
|
+
case "text": return applyFg(tok.v, defaultColor)
|
|
266
|
+
case "bold": return applyBold(applyFg(tok.v, defaultColor))
|
|
267
|
+
case "italic": return applyItalic(applyFg(tok.v, defaultColor))
|
|
268
|
+
case "code": return applyBold(applyFg(tok.v, defaultColor))
|
|
269
|
+
}
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
// applyFg/Bg/Bold/Italic each return a TextChunk. They composes via
|
|
273
|
+
// OpenTUI's chunk-level helpers; we don't go through the t`` template.
|
|
274
|
+
|
|
275
|
+
function applyFg(input: string | TextChunk, color: string): TextChunk {
|
|
276
|
+
return fg(color)(input)
|
|
277
|
+
}
|
|
278
|
+
function applyBg(input: string | TextChunk, color: string): TextChunk {
|
|
279
|
+
return bg(color)(input)
|
|
280
|
+
}
|
|
281
|
+
function applyBold(input: string | TextChunk): TextChunk {
|
|
282
|
+
return bold(input)
|
|
283
|
+
}
|
|
284
|
+
function applyItalic(input: string | TextChunk): TextChunk {
|
|
285
|
+
return italic(input)
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
function styled(chunks: TextChunk[]): StyledText {
|
|
289
|
+
return new StyledText(chunks)
|
|
290
|
+
}
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
// Codex-style messages.
|
|
2
|
+
// User: full-width subtle-highlight bar with › prefix.
|
|
3
|
+
// Assistant: inline text prefixed by • bullet, no border.
|
|
4
|
+
// System: same dim treatment as assistant but with the system tone color.
|
|
5
|
+
|
|
6
|
+
import { fg, t } from "@opentui/core"
|
|
7
|
+
import { theme } from "../theme.js"
|
|
8
|
+
import { Markdown } from "./Markdown.js"
|
|
9
|
+
|
|
10
|
+
export function UserMessage({ text }: { text: string }) {
|
|
11
|
+
return (
|
|
12
|
+
<box
|
|
13
|
+
flexDirection="column"
|
|
14
|
+
marginTop={1}
|
|
15
|
+
paddingLeft={1}
|
|
16
|
+
paddingRight={1}
|
|
17
|
+
backgroundColor={theme.userBg}
|
|
18
|
+
>
|
|
19
|
+
{splitLines(text).map((line, i) => (
|
|
20
|
+
<text
|
|
21
|
+
key={i}
|
|
22
|
+
content={i === 0 ? t`${fg(theme.dim)("› ")}${fg(theme.user)(line)}` : t` ${fg(theme.user)(line)}`}
|
|
23
|
+
bg={theme.userBg}
|
|
24
|
+
/>
|
|
25
|
+
))}
|
|
26
|
+
</box>
|
|
27
|
+
)
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export function AssistantMessage({
|
|
31
|
+
text,
|
|
32
|
+
thinking,
|
|
33
|
+
streaming,
|
|
34
|
+
}: {
|
|
35
|
+
text: string
|
|
36
|
+
thinking: string
|
|
37
|
+
streaming: boolean
|
|
38
|
+
}) {
|
|
39
|
+
return (
|
|
40
|
+
<box flexDirection="column" marginTop={1}>
|
|
41
|
+
{thinking ? (
|
|
42
|
+
<box flexDirection="column" marginBottom={text ? 1 : 0}>
|
|
43
|
+
<text content={t`${fg(theme.thinking)("● thinking")}${streaming ? fg(theme.dim)("…") : ""}`} />
|
|
44
|
+
{splitLines(thinking).map((line, i) => (
|
|
45
|
+
<text key={i} content={" " + line} fg={theme.dim} />
|
|
46
|
+
))}
|
|
47
|
+
</box>
|
|
48
|
+
) : null}
|
|
49
|
+
{text && text.trim() ? (
|
|
50
|
+
<>
|
|
51
|
+
<Markdown text={text} />
|
|
52
|
+
{streaming ? <text content={t`${fg(theme.dim)("▍")}`} /> : null}
|
|
53
|
+
</>
|
|
54
|
+
) : null}
|
|
55
|
+
{streaming && !(text && text.trim()) ? (
|
|
56
|
+
<text content={t`${fg(theme.dim)("…")}`} />
|
|
57
|
+
) : null}
|
|
58
|
+
</box>
|
|
59
|
+
)
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export function SystemMessage({ text, tone }: { text: string; tone?: "info" | "warn" | "error" }) {
|
|
63
|
+
const color = tone === "error" ? theme.toolError : tone === "warn" ? theme.toolRunning : theme.system
|
|
64
|
+
const lines = splitLines(text)
|
|
65
|
+
return (
|
|
66
|
+
<box flexDirection="column" marginTop={1}>
|
|
67
|
+
{lines.map((line, i) => {
|
|
68
|
+
const prefix = i === 0 ? fg(theme.bullet)("● ") : " "
|
|
69
|
+
return <text key={i} content={t`${prefix}${fg(color)(line)}`} />
|
|
70
|
+
})}
|
|
71
|
+
</box>
|
|
72
|
+
)
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
function splitLines(s: string): string[] {
|
|
76
|
+
return s.split("\n")
|
|
77
|
+
}
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import { fg, t } from "@opentui/core"
|
|
2
|
+
import type { PermissionMode } from "../agent/types.js"
|
|
3
|
+
import { theme } from "../theme.js"
|
|
4
|
+
|
|
5
|
+
const ORDER: PermissionMode[] = ["default", "acceptEdits", "bypassPermissions", "plan"]
|
|
6
|
+
|
|
7
|
+
export function nextMode(current: PermissionMode): PermissionMode {
|
|
8
|
+
const i = ORDER.indexOf(current)
|
|
9
|
+
return ORDER[(i + 1) % ORDER.length]
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export function PermissionBanner({ mode, busy }: { mode: PermissionMode; busy?: boolean }) {
|
|
13
|
+
if (mode === "default") return null
|
|
14
|
+
const { label, color } = banner(mode)
|
|
15
|
+
const hint = busy
|
|
16
|
+
? "(shift+tab to cycle · esc to interrupt)"
|
|
17
|
+
: "(shift+tab to cycle)"
|
|
18
|
+
return (
|
|
19
|
+
<text content={t`${fg(color)("▶▶ ")}${fg(color)(label)} ${fg(theme.dim)(hint)}`} />
|
|
20
|
+
)
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
function banner(mode: PermissionMode): { label: string; color: string } {
|
|
24
|
+
switch (mode) {
|
|
25
|
+
case "acceptEdits": return { label: "accept edits on", color: theme.modeAcceptEdits }
|
|
26
|
+
case "bypassPermissions": return { label: "bypass permissions on", color: theme.modeBypass }
|
|
27
|
+
case "plan": return { label: "plan mode on", color: theme.modePlan }
|
|
28
|
+
default: return { label: "", color: theme.dim }
|
|
29
|
+
}
|
|
30
|
+
}
|
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
// Generic interactive list picker with type-to-filter search.
|
|
2
|
+
// Up/Down navigate, Enter confirms, Esc cancels, printable keys edit query.
|
|
3
|
+
|
|
4
|
+
import { useState, useMemo, useEffect } from "react"
|
|
5
|
+
import { useKeyboard } from "@opentui/react"
|
|
6
|
+
import { fg, t } from "@opentui/core"
|
|
7
|
+
import { theme } from "../theme.js"
|
|
8
|
+
|
|
9
|
+
export interface PickerItem {
|
|
10
|
+
value: string
|
|
11
|
+
label: string
|
|
12
|
+
description?: string
|
|
13
|
+
badge?: string
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export interface PickerProps {
|
|
17
|
+
title: string
|
|
18
|
+
items: PickerItem[]
|
|
19
|
+
initialIndex?: number
|
|
20
|
+
onSelect: (value: string) => void
|
|
21
|
+
onCancel: () => void
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
const MAX_VISIBLE = 8
|
|
25
|
+
|
|
26
|
+
export function Picker({ title, items, initialIndex = 0, onSelect, onCancel }: PickerProps) {
|
|
27
|
+
const [query, setQuery] = useState("")
|
|
28
|
+
const [idx, setIdx] = useState(Math.min(initialIndex, Math.max(0, items.length - 1)))
|
|
29
|
+
|
|
30
|
+
const filtered = useMemo(() => {
|
|
31
|
+
if (!query) return items
|
|
32
|
+
const q = query.toLowerCase()
|
|
33
|
+
return items.filter(i =>
|
|
34
|
+
i.label.toLowerCase().includes(q)
|
|
35
|
+
|| (i.description ?? "").toLowerCase().includes(q)
|
|
36
|
+
|| (i.badge ?? "").toLowerCase().includes(q),
|
|
37
|
+
)
|
|
38
|
+
}, [items, query])
|
|
39
|
+
|
|
40
|
+
// Snap selection back into range whenever the filter changes.
|
|
41
|
+
useEffect(() => {
|
|
42
|
+
setIdx(i => Math.min(Math.max(0, i), Math.max(0, filtered.length - 1)))
|
|
43
|
+
}, [filtered.length])
|
|
44
|
+
|
|
45
|
+
useKeyboard(key => {
|
|
46
|
+
if (key.name === "up") {
|
|
47
|
+
setIdx(i => Math.max(0, i - 1))
|
|
48
|
+
} else if (key.name === "down") {
|
|
49
|
+
setIdx(i => Math.min(filtered.length - 1, i + 1))
|
|
50
|
+
} else if (key.name === "return") {
|
|
51
|
+
if (filtered.length > 0) onSelect(filtered[idx].value)
|
|
52
|
+
} else if (key.name === "escape") {
|
|
53
|
+
onCancel()
|
|
54
|
+
} else if (key.name === "backspace") {
|
|
55
|
+
setQuery(q => q.slice(0, -1))
|
|
56
|
+
} else if (
|
|
57
|
+
key.sequence
|
|
58
|
+
&& key.sequence.length === 1
|
|
59
|
+
&& key.sequence >= " "
|
|
60
|
+
&& key.sequence <= "~"
|
|
61
|
+
) {
|
|
62
|
+
setQuery(q => q + key.sequence)
|
|
63
|
+
}
|
|
64
|
+
})
|
|
65
|
+
|
|
66
|
+
if (items.length === 0) return null
|
|
67
|
+
|
|
68
|
+
const start = Math.max(
|
|
69
|
+
0,
|
|
70
|
+
Math.min(idx - Math.floor(MAX_VISIBLE / 2), Math.max(0, filtered.length - MAX_VISIBLE)),
|
|
71
|
+
)
|
|
72
|
+
const visible = filtered.slice(start, start + MAX_VISIBLE)
|
|
73
|
+
|
|
74
|
+
// Fixed column widths derived from the full item set so widths don't jiggle as you filter.
|
|
75
|
+
const labelWidth = Math.min(28, Math.max(...items.map(i => i.label.length)))
|
|
76
|
+
const badgeWidth = Math.min(12, Math.max(...items.map(i => (i.badge ?? "").length)))
|
|
77
|
+
|
|
78
|
+
// Total height: title + filter + blank + visible rows + blank + footer + padding(2)
|
|
79
|
+
const height = visible.length + 7
|
|
80
|
+
|
|
81
|
+
return (
|
|
82
|
+
<box
|
|
83
|
+
flexDirection="column"
|
|
84
|
+
flexShrink={0}
|
|
85
|
+
height={height}
|
|
86
|
+
borderStyle="rounded"
|
|
87
|
+
borderColor={theme.borderActive}
|
|
88
|
+
paddingLeft={1}
|
|
89
|
+
paddingRight={1}
|
|
90
|
+
paddingTop={0}
|
|
91
|
+
paddingBottom={0}
|
|
92
|
+
marginTop={1}
|
|
93
|
+
>
|
|
94
|
+
<text content={t`${fg(theme.accent)("› ")}${fg(theme.assistant)(title)}`} />
|
|
95
|
+
<text
|
|
96
|
+
content={t`${fg(theme.dim)(" search ")}${fg(theme.assistant)(query || " ")}${fg(theme.dim)(query ? "" : "(type to filter)")}`}
|
|
97
|
+
/>
|
|
98
|
+
<text content="" />
|
|
99
|
+
{visible.length === 0 ? (
|
|
100
|
+
<text content=" no matches" fg={theme.dim} />
|
|
101
|
+
) : (
|
|
102
|
+
visible.map((item, i) => {
|
|
103
|
+
const realIdx = start + i
|
|
104
|
+
const active = realIdx === idx
|
|
105
|
+
const marker = active ? "› " : " "
|
|
106
|
+
const markerColor = active ? theme.accent : theme.dim
|
|
107
|
+
const labelColor = active ? theme.assistant : theme.dim
|
|
108
|
+
const label = clip(item.label, labelWidth).padEnd(labelWidth)
|
|
109
|
+
const badge = clip(item.badge ?? "", badgeWidth).padEnd(badgeWidth)
|
|
110
|
+
const desc = item.description ?? ""
|
|
111
|
+
return (
|
|
112
|
+
<text
|
|
113
|
+
key={item.value}
|
|
114
|
+
content={t`${fg(markerColor)(marker)}${fg(labelColor)(label)} ${fg(theme.dim)(badge)} ${fg(theme.dim)(desc)}`}
|
|
115
|
+
/>
|
|
116
|
+
)
|
|
117
|
+
})
|
|
118
|
+
)}
|
|
119
|
+
<text content="" />
|
|
120
|
+
<text content="↑/↓ choose · enter confirm · esc cancel · type to filter" fg={theme.dim} />
|
|
121
|
+
</box>
|
|
122
|
+
)
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
function clip(s: string, max: number): string {
|
|
126
|
+
return s.length <= max ? s : s.slice(0, Math.max(0, max - 1)) + "…"
|
|
127
|
+
}
|