@yeshwanthyk/coding-agent 0.2.2
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/CHANGELOG.md +40 -0
- package/README.md +372 -0
- package/package.json +42 -0
- package/src/adapters/acp/index.ts +305 -0
- package/src/adapters/acp/protocol.ts +191 -0
- package/src/adapters/acp/session.ts +289 -0
- package/src/adapters/acp/updates.ts +96 -0
- package/src/adapters/cli/headless.ts +112 -0
- package/src/adapters/cli/validate.ts +50 -0
- package/src/adapters/tui/app.tsx +39 -0
- package/src/agent-events.ts +671 -0
- package/src/args.ts +102 -0
- package/src/autocomplete-commands.ts +102 -0
- package/src/commands.ts +23 -0
- package/src/compact-handler.ts +272 -0
- package/src/components/Footer.tsx +49 -0
- package/src/components/Header.tsx +218 -0
- package/src/components/MessageList.tsx +380 -0
- package/src/config.ts +1 -0
- package/src/domain/commands/builtin/clear.ts +14 -0
- package/src/domain/commands/builtin/compact.ts +96 -0
- package/src/domain/commands/builtin/conceal.ts +9 -0
- package/src/domain/commands/builtin/diffwrap.ts +9 -0
- package/src/domain/commands/builtin/editor.ts +24 -0
- package/src/domain/commands/builtin/exit.ts +14 -0
- package/src/domain/commands/builtin/followup.ts +24 -0
- package/src/domain/commands/builtin/index.ts +29 -0
- package/src/domain/commands/builtin/login.ts +118 -0
- package/src/domain/commands/builtin/model.ts +66 -0
- package/src/domain/commands/builtin/status.ts +32 -0
- package/src/domain/commands/builtin/steer.ts +24 -0
- package/src/domain/commands/builtin/theme.ts +23 -0
- package/src/domain/commands/builtin/thinking.ts +16 -0
- package/src/domain/commands/helpers.ts +41 -0
- package/src/domain/commands/registry.ts +42 -0
- package/src/domain/commands/types.ts +69 -0
- package/src/domain/messaging/content.ts +117 -0
- package/src/editor.ts +103 -0
- package/src/extensibility/schema.ts +1 -0
- package/src/extensibility/validation.ts +1 -0
- package/src/hooks/index.ts +1 -0
- package/src/hooks/useAgentEvents.ts +28 -0
- package/src/hooks/useEditorBridge.ts +101 -0
- package/src/hooks/useGitStatus.ts +28 -0
- package/src/hooks/usePromptQueue.ts +7 -0
- package/src/hooks/useSessionController.ts +5 -0
- package/src/hooks/useSpinner.ts +28 -0
- package/src/hooks/useToastManager.ts +26 -0
- package/src/index.ts +188 -0
- package/src/keyboard-handler.ts +134 -0
- package/src/profiler.ts +40 -0
- package/src/runtime/context.tsx +16 -0
- package/src/runtime/factory.ts +63 -0
- package/src/runtime/git/git-info.ts +25 -0
- package/src/runtime/session/session-controller.ts +208 -0
- package/src/session-manager.ts +1 -0
- package/src/session-picker.tsx +134 -0
- package/src/shell-runner.ts +134 -0
- package/src/syntax-highlighting.ts +114 -0
- package/src/theme-names.ts +37 -0
- package/src/tool-ui-contracts.ts +77 -0
- package/src/tui-open-rendering.tsx +565 -0
- package/src/types.ts +89 -0
- package/src/ui/app-shell/TuiApp.tsx +586 -0
- package/src/ui/clipboard/osc52.ts +18 -0
- package/src/ui/components/modals/ConfirmModal.tsx +52 -0
- package/src/ui/components/modals/EditorModal.tsx +39 -0
- package/src/ui/components/modals/InputModal.tsx +30 -0
- package/src/ui/components/modals/ModalContainer.tsx +67 -0
- package/src/ui/components/modals/SelectModal.tsx +48 -0
- package/src/ui/components/modals/index.ts +4 -0
- package/src/ui/features/composer/Composer.tsx +73 -0
- package/src/ui/features/composer/SlashCommandHandler.ts +58 -0
- package/src/ui/features/composer/keyboard.ts +3 -0
- package/src/ui/features/main-view/MainView.tsx +367 -0
- package/src/ui/features/message-pane/MessagePane.tsx +34 -0
- package/src/ui/hooks/useModals.ts +74 -0
- package/src/ui/state/app-store.ts +67 -0
- package/src/utils.ts +14 -0
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import { useKeyboard } from "@opentui/solid"
|
|
2
|
+
import { Dialog, Input } from "@yeshwanthyk/open-tui"
|
|
3
|
+
import type { JSX } from "solid-js"
|
|
4
|
+
|
|
5
|
+
export interface InputModalProps {
|
|
6
|
+
title: string
|
|
7
|
+
placeholder?: string
|
|
8
|
+
onSubmit: (value: string | undefined) => void
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export function InputModal(props: InputModalProps): JSX.Element {
|
|
12
|
+
useKeyboard((e: { name: string }) => {
|
|
13
|
+
if (e.name === "escape") {
|
|
14
|
+
props.onSubmit(undefined)
|
|
15
|
+
}
|
|
16
|
+
})
|
|
17
|
+
|
|
18
|
+
return (
|
|
19
|
+
<Dialog open={true} title={props.title} closeOnOverlayClick={false}>
|
|
20
|
+
<Input
|
|
21
|
+
placeholder={props.placeholder}
|
|
22
|
+
focused={true}
|
|
23
|
+
onSubmit={(value) => props.onSubmit(value || undefined)}
|
|
24
|
+
onEscape={() => props.onSubmit(undefined)}
|
|
25
|
+
/>
|
|
26
|
+
<box height={1} />
|
|
27
|
+
<text>Enter to submit • Esc to cancel</text>
|
|
28
|
+
</Dialog>
|
|
29
|
+
)
|
|
30
|
+
}
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
import { Match, Switch } from "solid-js"
|
|
2
|
+
import type { ModalState } from "../../hooks/useModals.js"
|
|
3
|
+
import { SelectModal } from "./SelectModal.js"
|
|
4
|
+
import { InputModal } from "./InputModal.js"
|
|
5
|
+
import { ConfirmModal } from "./ConfirmModal.js"
|
|
6
|
+
import { EditorModal } from "./EditorModal.js"
|
|
7
|
+
import type { JSX } from "solid-js"
|
|
8
|
+
|
|
9
|
+
export interface ModalContainerProps {
|
|
10
|
+
modalState: ModalState
|
|
11
|
+
onClose: () => void
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export function ModalContainer(props: ModalContainerProps): JSX.Element {
|
|
15
|
+
return (
|
|
16
|
+
<Switch>
|
|
17
|
+
<Match when={props.modalState?.type === "select" && props.modalState}>
|
|
18
|
+
{(state) => (
|
|
19
|
+
<SelectModal
|
|
20
|
+
title={state().title}
|
|
21
|
+
options={(state() as { options: string[] }).options}
|
|
22
|
+
onSelect={(value) => {
|
|
23
|
+
(state() as { resolve: (v: string | undefined) => void }).resolve(value)
|
|
24
|
+
props.onClose()
|
|
25
|
+
}}
|
|
26
|
+
/>
|
|
27
|
+
)}
|
|
28
|
+
</Match>
|
|
29
|
+
<Match when={props.modalState?.type === "input" && props.modalState}>
|
|
30
|
+
{(state) => (
|
|
31
|
+
<InputModal
|
|
32
|
+
title={state().title}
|
|
33
|
+
placeholder={(state() as { placeholder?: string }).placeholder}
|
|
34
|
+
onSubmit={(value) => {
|
|
35
|
+
(state() as { resolve: (v: string | undefined) => void }).resolve(value)
|
|
36
|
+
props.onClose()
|
|
37
|
+
}}
|
|
38
|
+
/>
|
|
39
|
+
)}
|
|
40
|
+
</Match>
|
|
41
|
+
<Match when={props.modalState?.type === "confirm" && props.modalState}>
|
|
42
|
+
{(state) => (
|
|
43
|
+
<ConfirmModal
|
|
44
|
+
title={state().title}
|
|
45
|
+
message={(state() as { message: string }).message}
|
|
46
|
+
onConfirm={(confirmed) => {
|
|
47
|
+
(state() as { resolve: (v: boolean) => void }).resolve(confirmed)
|
|
48
|
+
props.onClose()
|
|
49
|
+
}}
|
|
50
|
+
/>
|
|
51
|
+
)}
|
|
52
|
+
</Match>
|
|
53
|
+
<Match when={props.modalState?.type === "editor" && props.modalState}>
|
|
54
|
+
{(state) => (
|
|
55
|
+
<EditorModal
|
|
56
|
+
title={state().title}
|
|
57
|
+
initialText={(state() as { initialText?: string }).initialText}
|
|
58
|
+
onSubmit={(value) => {
|
|
59
|
+
(state() as { resolve: (v: string | undefined) => void }).resolve(value)
|
|
60
|
+
props.onClose()
|
|
61
|
+
}}
|
|
62
|
+
/>
|
|
63
|
+
)}
|
|
64
|
+
</Match>
|
|
65
|
+
</Switch>
|
|
66
|
+
)
|
|
67
|
+
}
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
import { createSignal, type JSX } from "solid-js"
|
|
2
|
+
import { useKeyboard } from "@opentui/solid"
|
|
3
|
+
import { Dialog, SelectList, type SelectListRef, type SelectItem } from "@yeshwanthyk/open-tui"
|
|
4
|
+
|
|
5
|
+
export interface SelectModalProps {
|
|
6
|
+
title: string
|
|
7
|
+
options: string[]
|
|
8
|
+
onSelect: (value: string | undefined) => void
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export function SelectModal(props: SelectModalProps): JSX.Element {
|
|
12
|
+
const [selectedIndex, setSelectedIndex] = createSignal(0)
|
|
13
|
+
let listRef: SelectListRef | undefined
|
|
14
|
+
|
|
15
|
+
const items: SelectItem[] = props.options.map((opt) => ({
|
|
16
|
+
value: opt,
|
|
17
|
+
label: opt,
|
|
18
|
+
}))
|
|
19
|
+
|
|
20
|
+
useKeyboard((e: { name: string }) => {
|
|
21
|
+
if (e.name === "up") {
|
|
22
|
+
listRef?.moveUp()
|
|
23
|
+
} else if (e.name === "down") {
|
|
24
|
+
listRef?.moveDown()
|
|
25
|
+
} else if (e.name === "return") {
|
|
26
|
+
const item = listRef?.getSelectedItem()
|
|
27
|
+
props.onSelect(item?.value)
|
|
28
|
+
} else if (e.name === "escape") {
|
|
29
|
+
props.onSelect(undefined)
|
|
30
|
+
}
|
|
31
|
+
})
|
|
32
|
+
|
|
33
|
+
return (
|
|
34
|
+
<Dialog open={true} title={props.title} closeOnOverlayClick={false}>
|
|
35
|
+
<SelectList
|
|
36
|
+
items={items}
|
|
37
|
+
selectedIndex={selectedIndex()}
|
|
38
|
+
onSelectionChange={(_, index) => setSelectedIndex(index)}
|
|
39
|
+
onSelect={(item) => props.onSelect(item.value)}
|
|
40
|
+
onCancel={() => props.onSelect(undefined)}
|
|
41
|
+
maxVisible={10}
|
|
42
|
+
ref={(ref) => { listRef = ref }}
|
|
43
|
+
/>
|
|
44
|
+
<box height={1} />
|
|
45
|
+
<text>↑/↓ navigate • Enter select • Esc cancel</text>
|
|
46
|
+
</Dialog>
|
|
47
|
+
)
|
|
48
|
+
}
|
|
@@ -0,0 +1,4 @@
|
|
|
1
|
+
export { SelectModal, type SelectModalProps } from "./SelectModal.js"
|
|
2
|
+
export { InputModal, type InputModalProps } from "./InputModal.js"
|
|
3
|
+
export { ConfirmModal, type ConfirmModalProps } from "./ConfirmModal.js"
|
|
4
|
+
export { EditorModal, type EditorModalProps } from "./EditorModal.js"
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
import { TextareaRenderable, type KeyEvent } from "@opentui/core"
|
|
2
|
+
import { Show } from "solid-js"
|
|
3
|
+
import { SelectList, type AutocompleteItem, type SelectItem, type Theme } from "@yeshwanthyk/open-tui"
|
|
4
|
+
|
|
5
|
+
export interface ComposerProps {
|
|
6
|
+
theme: Theme
|
|
7
|
+
isBashMode: () => boolean
|
|
8
|
+
showAutocomplete: () => boolean
|
|
9
|
+
autocompleteItems: () => AutocompleteItem[]
|
|
10
|
+
autocompleteIndex: () => number
|
|
11
|
+
textareaRef: (ref: TextareaRenderable) => void
|
|
12
|
+
onContentChange: () => void
|
|
13
|
+
onSubmit: () => void
|
|
14
|
+
onKeyDown: (event: KeyEvent) => void
|
|
15
|
+
terminalWidth: () => number
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export function Composer(props: ComposerProps) {
|
|
19
|
+
return (
|
|
20
|
+
<>
|
|
21
|
+
<Show when={props.showAutocomplete() && props.autocompleteItems().length > 0}>
|
|
22
|
+
<box flexDirection="column" borderColor={props.theme.border} maxHeight={15} flexShrink={0}>
|
|
23
|
+
<SelectList
|
|
24
|
+
items={props.autocompleteItems().map((item): SelectItem => ({
|
|
25
|
+
value: item.value,
|
|
26
|
+
label: item.label,
|
|
27
|
+
description: item.description,
|
|
28
|
+
}))}
|
|
29
|
+
selectedIndex={props.autocompleteIndex()}
|
|
30
|
+
maxVisible={12}
|
|
31
|
+
width={Math.max(10, props.terminalWidth() - 2)}
|
|
32
|
+
/>
|
|
33
|
+
<text fg={props.theme.textMuted}>{" "}↑↓ navigate · Tab select · Esc cancel</text>
|
|
34
|
+
</box>
|
|
35
|
+
</Show>
|
|
36
|
+
<box border={["top"]} borderColor={props.isBashMode() ? props.theme.warning : props.theme.border} paddingTop={1} flexShrink={0}>
|
|
37
|
+
<textarea
|
|
38
|
+
ref={(r: TextareaRenderable) => {
|
|
39
|
+
props.textareaRef(r)
|
|
40
|
+
}}
|
|
41
|
+
placeholder=""
|
|
42
|
+
backgroundColor={props.theme.background}
|
|
43
|
+
focusedBackgroundColor={props.theme.background}
|
|
44
|
+
textColor={props.theme.text}
|
|
45
|
+
focusedTextColor={props.theme.text}
|
|
46
|
+
cursorColor={props.theme.text}
|
|
47
|
+
minHeight={1}
|
|
48
|
+
maxHeight={6}
|
|
49
|
+
keyBindings={[
|
|
50
|
+
{ name: "return", shift: true, action: "newline" as const },
|
|
51
|
+
{ name: "return", ctrl: true, action: "newline" as const },
|
|
52
|
+
{ name: "return", meta: true, action: "newline" as const },
|
|
53
|
+
{ name: "return", action: "submit" as const },
|
|
54
|
+
{ name: "left", action: "move-left" as const },
|
|
55
|
+
{ name: "right", action: "move-right" as const },
|
|
56
|
+
{ name: "backspace", action: "backspace" as const },
|
|
57
|
+
{ name: "delete", action: "delete" as const },
|
|
58
|
+
{ name: "a", ctrl: true, action: "line-home" as const },
|
|
59
|
+
{ name: "e", ctrl: true, action: "line-end" as const },
|
|
60
|
+
{ name: "k", ctrl: true, action: "delete-to-line-end" as const },
|
|
61
|
+
{ name: "u", ctrl: true, action: "delete-to-line-start" as const },
|
|
62
|
+
{ name: "w", ctrl: true, action: "delete-word-backward" as const },
|
|
63
|
+
]}
|
|
64
|
+
onKeyDown={(event) => {
|
|
65
|
+
props.onKeyDown(event)
|
|
66
|
+
}}
|
|
67
|
+
onContentChange={props.onContentChange}
|
|
68
|
+
onSubmit={props.onSubmit}
|
|
69
|
+
/>
|
|
70
|
+
</box>
|
|
71
|
+
</>
|
|
72
|
+
)
|
|
73
|
+
}
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
import type { CommandContext } from "../../../commands.js"
|
|
2
|
+
import { handleSlashCommand } from "../../../commands.js"
|
|
3
|
+
import {
|
|
4
|
+
tryExpandCustomCommand,
|
|
5
|
+
type CustomCommand,
|
|
6
|
+
} from "@yeshwanthyk/runtime-effect/extensibility/custom-commands.js"
|
|
7
|
+
|
|
8
|
+
export interface SlashCommandBridge {
|
|
9
|
+
commandContext: CommandContext
|
|
10
|
+
customCommands: Map<string, CustomCommand>
|
|
11
|
+
builtInCommandNames: Set<string>
|
|
12
|
+
onExpand: (expanded: string) => void | Promise<void>
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Handles slash command processing, returning true when the command is consumed.
|
|
17
|
+
* Checks hook commands first, then built-in commands, then custom commands.
|
|
18
|
+
*/
|
|
19
|
+
export const handleSlashInput = async (input: string, bridge: SlashCommandBridge): Promise<boolean> => {
|
|
20
|
+
const trimmed = input.trim()
|
|
21
|
+
|
|
22
|
+
// Parse command name and args
|
|
23
|
+
const match = trimmed.match(/^\/(\S+)(?:\s+([\s\S]*))?$/)
|
|
24
|
+
if (!match) return false
|
|
25
|
+
|
|
26
|
+
const cmdName = match[1]!
|
|
27
|
+
const cmdArgs = match[2] ?? ""
|
|
28
|
+
|
|
29
|
+
// Check hook-registered commands first
|
|
30
|
+
const { hookRunner } = bridge.commandContext
|
|
31
|
+
if (hookRunner) {
|
|
32
|
+
const hookCmd = hookRunner.getCommand(cmdName)
|
|
33
|
+
if (hookCmd) {
|
|
34
|
+
try {
|
|
35
|
+
await hookCmd.handler(cmdArgs, hookRunner.getContext())
|
|
36
|
+
} catch (err) {
|
|
37
|
+
// Hook command errors are logged but don't crash
|
|
38
|
+
console.error(`Hook command error [${cmdName}]:`, err)
|
|
39
|
+
}
|
|
40
|
+
return true
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
// Then check built-in commands
|
|
45
|
+
const handled = handleSlashCommand(trimmed, bridge.commandContext)
|
|
46
|
+
if (handled instanceof Promise ? await handled : handled) {
|
|
47
|
+
return true
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
// Finally check custom commands
|
|
51
|
+
const expanded = tryExpandCustomCommand(trimmed, bridge.builtInCommandNames, bridge.customCommands)
|
|
52
|
+
if (expanded !== null) {
|
|
53
|
+
await bridge.onExpand(expanded)
|
|
54
|
+
return true
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
return false
|
|
58
|
+
}
|
|
@@ -0,0 +1,367 @@
|
|
|
1
|
+
import { TextareaRenderable } from "@opentui/core"
|
|
2
|
+
import { useTerminalDimensions } from "@opentui/solid"
|
|
3
|
+
import {
|
|
4
|
+
CombinedAutocompleteProvider,
|
|
5
|
+
ToastViewport,
|
|
6
|
+
copyToClipboard,
|
|
7
|
+
useRenderer,
|
|
8
|
+
useTheme,
|
|
9
|
+
type AutocompleteItem,
|
|
10
|
+
} from "@yeshwanthyk/open-tui"
|
|
11
|
+
import type { ThinkingLevel } from "@yeshwanthyk/agent-core"
|
|
12
|
+
import type { KnownProvider } from "@yeshwanthyk/ai"
|
|
13
|
+
import type { LspManager } from "@yeshwanthyk/lsp"
|
|
14
|
+
import { createSignal, createEffect, onMount } from "solid-js"
|
|
15
|
+
import { createAutocompleteCommands } from "../../../autocomplete-commands.js"
|
|
16
|
+
import type { CustomCommand } from "@yeshwanthyk/runtime-effect/extensibility/custom-commands.js"
|
|
17
|
+
import { Footer } from "../../../components/Footer.js"
|
|
18
|
+
import { Header } from "../../../components/Header.js"
|
|
19
|
+
import type { ActivityState, ToolBlock, UIMessage } from "../../../types.js"
|
|
20
|
+
import type { QueueCounts } from "@yeshwanthyk/runtime-effect/session/prompt-queue.js"
|
|
21
|
+
import { useGitStatus } from "../../../hooks/useGitStatus.js"
|
|
22
|
+
import { useSpinner } from "../../../hooks/useSpinner.js"
|
|
23
|
+
import { useToastManager } from "../../../hooks/useToastManager.js"
|
|
24
|
+
import { useEditorBridge } from "../../../hooks/useEditorBridge.js"
|
|
25
|
+
import { MessagePane } from "../message-pane/MessagePane.js"
|
|
26
|
+
import { Composer } from "../composer/Composer.js"
|
|
27
|
+
import { createComposerKeyboardHandler } from "../composer/keyboard.js"
|
|
28
|
+
import type { ValidationIssue } from "@ext/schema.js"
|
|
29
|
+
|
|
30
|
+
export interface MainViewProps {
|
|
31
|
+
validationIssues?: ValidationIssue[]
|
|
32
|
+
messages: UIMessage[]
|
|
33
|
+
toolBlocks: ToolBlock[]
|
|
34
|
+
isResponding: boolean
|
|
35
|
+
activityState: ActivityState
|
|
36
|
+
thinkingVisible: boolean
|
|
37
|
+
modelId: string
|
|
38
|
+
thinking: ThinkingLevel
|
|
39
|
+
provider: KnownProvider
|
|
40
|
+
contextTokens: number
|
|
41
|
+
contextWindow: number
|
|
42
|
+
queueCounts: QueueCounts
|
|
43
|
+
retryStatus: string | null
|
|
44
|
+
turnCount: number
|
|
45
|
+
lspActive: boolean
|
|
46
|
+
diffWrapMode: "word" | "none"
|
|
47
|
+
concealMarkdown: boolean
|
|
48
|
+
customCommands: Map<string, CustomCommand>
|
|
49
|
+
onSubmit: (text: string, clearFn?: () => void) => void
|
|
50
|
+
onAbort: () => string | null
|
|
51
|
+
onToggleThinking: () => void
|
|
52
|
+
onCycleModel: () => void
|
|
53
|
+
onCycleThinking: () => void
|
|
54
|
+
exitHandlerRef: { current: () => void }
|
|
55
|
+
editorOpenRef: { current: () => Promise<void> | void }
|
|
56
|
+
setEditorTextRef: { current: (text: string) => void }
|
|
57
|
+
getEditorTextRef: { current: () => string }
|
|
58
|
+
showToastRef: { current: (title: string, message: string, variant?: "info" | "warning" | "success" | "error") => void }
|
|
59
|
+
onBeforeExit?: () => Promise<void>
|
|
60
|
+
editor?: import("../../../config.js").EditorConfig
|
|
61
|
+
lsp: LspManager
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
export function MainView(props: MainViewProps) {
|
|
65
|
+
const { theme } = useTheme()
|
|
66
|
+
const dimensions = useTerminalDimensions()
|
|
67
|
+
let textareaRef: TextareaRenderable | undefined
|
|
68
|
+
const lastCtrlC = { current: 0 }
|
|
69
|
+
const branch = useGitStatus()
|
|
70
|
+
const spinnerFrame = useSpinner(() => props.activityState)
|
|
71
|
+
const renderer = useRenderer()
|
|
72
|
+
const { toasts, pushToast } = useToastManager()
|
|
73
|
+
const shownValidationKeys = new Set<string>()
|
|
74
|
+
createEffect(() => {
|
|
75
|
+
const issues = props.validationIssues ?? []
|
|
76
|
+
const showList = issues.slice(0, 3)
|
|
77
|
+
for (const issue of showList) {
|
|
78
|
+
const key = `${issue.severity}:${issue.path}:${issue.message}`
|
|
79
|
+
if (shownValidationKeys.has(key)) continue
|
|
80
|
+
shownValidationKeys.add(key)
|
|
81
|
+
console.error(`[marvin][extension][${issue.kind}] ${issue.path}: ${issue.message}`)
|
|
82
|
+
pushToast(
|
|
83
|
+
{
|
|
84
|
+
title: issue.severity === "error" ? "Extension error" : "Extension warning",
|
|
85
|
+
message: `${issue.path}: ${issue.message}`,
|
|
86
|
+
variant: issue.severity === "error" ? "error" : "warning",
|
|
87
|
+
},
|
|
88
|
+
6000,
|
|
89
|
+
)
|
|
90
|
+
}
|
|
91
|
+
})
|
|
92
|
+
const { openBuffer, editFile } = useEditorBridge({
|
|
93
|
+
editor: props.editor,
|
|
94
|
+
renderer,
|
|
95
|
+
pushToast,
|
|
96
|
+
isResponding: () => props.isResponding,
|
|
97
|
+
onSubmit: (text) => props.onSubmit(text),
|
|
98
|
+
})
|
|
99
|
+
|
|
100
|
+
const builtInAutocomplete = createAutocompleteCommands(() => ({ currentProvider: props.provider }))
|
|
101
|
+
const customAutocomplete = Array.from(props.customCommands.values()).map((cmd) => ({
|
|
102
|
+
name: cmd.name,
|
|
103
|
+
description: cmd.description,
|
|
104
|
+
}))
|
|
105
|
+
const autocompleteProvider = new CombinedAutocompleteProvider([...builtInAutocomplete, ...customAutocomplete], process.cwd())
|
|
106
|
+
const [autocompleteItems, setAutocompleteItems] = createSignal<AutocompleteItem[]>([])
|
|
107
|
+
const [autocompletePrefix, setAutocompletePrefix] = createSignal("")
|
|
108
|
+
const [autocompleteIndex, setAutocompleteIndex] = createSignal(0)
|
|
109
|
+
const [showAutocomplete, setShowAutocomplete] = createSignal(false)
|
|
110
|
+
const [isBashMode, setIsBashMode] = createSignal(false)
|
|
111
|
+
let suppressNextAutocompleteUpdate = false
|
|
112
|
+
|
|
113
|
+
const updateAutocomplete = (text: string, cursorLine: number, cursorCol: number) => {
|
|
114
|
+
const lines = text.split("\n")
|
|
115
|
+
const currentLine = lines[cursorLine] ?? ""
|
|
116
|
+
const beforeCursor = currentLine.slice(0, cursorCol)
|
|
117
|
+
|
|
118
|
+
if (beforeCursor.trim() === "") {
|
|
119
|
+
setShowAutocomplete(false)
|
|
120
|
+
setAutocompleteItems([])
|
|
121
|
+
return
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
const result = autocompleteProvider.getSuggestions(lines, cursorLine, cursorCol)
|
|
125
|
+
if (result && result.items.length > 0) {
|
|
126
|
+
const prevPrefix = autocompletePrefix()
|
|
127
|
+
const newItems = result.items.slice(0, 30)
|
|
128
|
+
setAutocompleteItems(newItems)
|
|
129
|
+
setAutocompletePrefix(result.prefix)
|
|
130
|
+
if (result.prefix !== prevPrefix) {
|
|
131
|
+
setAutocompleteIndex(0)
|
|
132
|
+
} else {
|
|
133
|
+
setAutocompleteIndex((i) => Math.min(i, newItems.length - 1))
|
|
134
|
+
}
|
|
135
|
+
setShowAutocomplete(true)
|
|
136
|
+
} else {
|
|
137
|
+
setShowAutocomplete(false)
|
|
138
|
+
setAutocompleteItems([])
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
const applyAutocomplete = () => {
|
|
143
|
+
if (!showAutocomplete() || !textareaRef) return false
|
|
144
|
+
const items = autocompleteItems()
|
|
145
|
+
const idx = autocompleteIndex()
|
|
146
|
+
if (idx < 0 || idx >= items.length) return false
|
|
147
|
+
const cursor = textareaRef.logicalCursor
|
|
148
|
+
const text = textareaRef.plainText
|
|
149
|
+
const lines = text.split("\n")
|
|
150
|
+
const result = autocompleteProvider.applyCompletion(lines, cursor.row, cursor.col, items[idx]!, autocompletePrefix())
|
|
151
|
+
const newText = result.lines.join("\n")
|
|
152
|
+
if (newText === text) {
|
|
153
|
+
setShowAutocomplete(false)
|
|
154
|
+
setAutocompleteItems([])
|
|
155
|
+
return false
|
|
156
|
+
}
|
|
157
|
+
suppressNextAutocompleteUpdate = true
|
|
158
|
+
textareaRef.replaceText(newText)
|
|
159
|
+
textareaRef.editBuffer.setCursorToLineCol(result.cursorLine, result.cursorCol)
|
|
160
|
+
setShowAutocomplete(false)
|
|
161
|
+
setAutocompleteItems([])
|
|
162
|
+
return true
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
onMount(() => {
|
|
166
|
+
textareaRef?.focus()
|
|
167
|
+
})
|
|
168
|
+
|
|
169
|
+
const exitApp = async () => {
|
|
170
|
+
try {
|
|
171
|
+
renderer.destroy()
|
|
172
|
+
await props.onBeforeExit?.()
|
|
173
|
+
} finally {
|
|
174
|
+
process.exit(0)
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
props.exitHandlerRef.current = exitApp
|
|
178
|
+
props.setEditorTextRef.current = (text: string) => textareaRef?.setText(text)
|
|
179
|
+
props.getEditorTextRef.current = () => textareaRef?.plainText ?? ""
|
|
180
|
+
props.showToastRef.current = (title, message, variant = "info") => {
|
|
181
|
+
pushToast({ title, message, variant }, 3000)
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
const copySelectionToClipboard = () => {
|
|
185
|
+
const sel = renderer.getSelection()
|
|
186
|
+
if (!sel) return
|
|
187
|
+
const text = sel.getSelectedText()
|
|
188
|
+
if (!text || text.length === 0) return
|
|
189
|
+
copyToClipboard(text)
|
|
190
|
+
pushToast({ title: "Copied to clipboard", variant: "success" }, 1500)
|
|
191
|
+
renderer.clearSelection()
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
const openEditorFromTui = async () => {
|
|
195
|
+
if (!textareaRef) return
|
|
196
|
+
setShowAutocomplete(false)
|
|
197
|
+
setAutocompleteItems([])
|
|
198
|
+
textareaRef.clear()
|
|
199
|
+
|
|
200
|
+
const content = await openBuffer("")
|
|
201
|
+
if (content === undefined) return
|
|
202
|
+
suppressNextAutocompleteUpdate = true
|
|
203
|
+
textareaRef.setText(content)
|
|
204
|
+
textareaRef.focus()
|
|
205
|
+
const lines = content.split("\n")
|
|
206
|
+
const lastLine = Math.max(0, lines.length - 1)
|
|
207
|
+
const lastCol = lines[lastLine]?.length ?? 0
|
|
208
|
+
textareaRef.editBuffer.setCursorToLineCol(lastLine, lastCol)
|
|
209
|
+
updateAutocomplete(content, lastLine, lastCol)
|
|
210
|
+
}
|
|
211
|
+
props.editorOpenRef.current = openEditorFromTui
|
|
212
|
+
|
|
213
|
+
const handleEditFile = (filePath: string, line?: number) => {
|
|
214
|
+
void editFile(filePath, line)
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
let prevContextWindow = props.contextWindow
|
|
218
|
+
createEffect(() => {
|
|
219
|
+
const newWindow = props.contextWindow
|
|
220
|
+
const oldWindow = prevContextWindow
|
|
221
|
+
prevContextWindow = newWindow
|
|
222
|
+
if (oldWindow <= 0 || newWindow >= oldWindow) return
|
|
223
|
+
|
|
224
|
+
const tokens = props.contextTokens
|
|
225
|
+
if (tokens <= 0) return
|
|
226
|
+
|
|
227
|
+
const usagePct = (tokens / newWindow) * 100
|
|
228
|
+
const remaining = newWindow - tokens
|
|
229
|
+
const formatK = (n: number) => (n >= 1000 ? `${Math.round(n / 1000)}k` : String(n))
|
|
230
|
+
|
|
231
|
+
if (usagePct > 100) {
|
|
232
|
+
pushToast(
|
|
233
|
+
{
|
|
234
|
+
title: `Context overflow: ${formatK(tokens)}/${formatK(newWindow)}`,
|
|
235
|
+
message: "Run /compact before continuing",
|
|
236
|
+
variant: "error",
|
|
237
|
+
},
|
|
238
|
+
5000,
|
|
239
|
+
)
|
|
240
|
+
} else if (usagePct > 85) {
|
|
241
|
+
pushToast(
|
|
242
|
+
{
|
|
243
|
+
title: `Context near limit: ${formatK(tokens)}/${formatK(newWindow)}`,
|
|
244
|
+
message: `${formatK(remaining)} remaining`,
|
|
245
|
+
variant: "warning",
|
|
246
|
+
},
|
|
247
|
+
4000,
|
|
248
|
+
)
|
|
249
|
+
}
|
|
250
|
+
})
|
|
251
|
+
|
|
252
|
+
const [expandedToolIds, setExpandedToolIds] = createSignal<Set<string>>(new Set())
|
|
253
|
+
const isToolExpanded = (id: string) => expandedToolIds().has(id)
|
|
254
|
+
const toggleToolExpanded = (id: string) =>
|
|
255
|
+
setExpandedToolIds((prev) => {
|
|
256
|
+
const next = new Set(prev)
|
|
257
|
+
if (next.has(id)) next.delete(id)
|
|
258
|
+
else next.add(id)
|
|
259
|
+
return next
|
|
260
|
+
})
|
|
261
|
+
const toggleLastToolExpanded = () => {
|
|
262
|
+
const last = props.toolBlocks[props.toolBlocks.length - 1]
|
|
263
|
+
if (last) toggleToolExpanded(last.id)
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
const [expandedThinkingIds, setExpandedThinkingIds] = createSignal<Set<string>>(new Set())
|
|
267
|
+
const isThinkingExpanded = (id: string) => expandedThinkingIds().has(id)
|
|
268
|
+
const toggleThinkingExpanded = (id: string) =>
|
|
269
|
+
setExpandedThinkingIds((prev) => {
|
|
270
|
+
const next = new Set(prev)
|
|
271
|
+
if (next.has(id)) next.delete(id)
|
|
272
|
+
else next.add(id)
|
|
273
|
+
return next
|
|
274
|
+
})
|
|
275
|
+
|
|
276
|
+
const handleKeyDown = createComposerKeyboardHandler({
|
|
277
|
+
showAutocomplete,
|
|
278
|
+
autocompleteItems,
|
|
279
|
+
setAutocompleteIndex,
|
|
280
|
+
setShowAutocomplete,
|
|
281
|
+
applyAutocomplete,
|
|
282
|
+
isResponding: () => props.isResponding,
|
|
283
|
+
retryStatus: () => props.retryStatus,
|
|
284
|
+
onAbort: props.onAbort,
|
|
285
|
+
onToggleThinking: props.onToggleThinking,
|
|
286
|
+
onCycleModel: props.onCycleModel,
|
|
287
|
+
onCycleThinking: props.onCycleThinking,
|
|
288
|
+
toggleLastToolExpanded,
|
|
289
|
+
copySelectionToClipboard,
|
|
290
|
+
clearEditor: () => textareaRef?.clear(),
|
|
291
|
+
setEditorText: (text) => textareaRef?.setText(text),
|
|
292
|
+
lastCtrlC,
|
|
293
|
+
onExit: exitApp,
|
|
294
|
+
})
|
|
295
|
+
|
|
296
|
+
const handleComposerContentChange = () => {
|
|
297
|
+
if (!textareaRef) return
|
|
298
|
+
if (suppressNextAutocompleteUpdate) {
|
|
299
|
+
suppressNextAutocompleteUpdate = false
|
|
300
|
+
return
|
|
301
|
+
}
|
|
302
|
+
const text = textareaRef.plainText
|
|
303
|
+
setIsBashMode(text.trimStart().startsWith("!"))
|
|
304
|
+
const cursor = textareaRef.logicalCursor
|
|
305
|
+
updateAutocomplete(text, cursor.row, cursor.col)
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
return (
|
|
309
|
+
<box
|
|
310
|
+
flexDirection="column"
|
|
311
|
+
width={dimensions().width}
|
|
312
|
+
height={dimensions().height}
|
|
313
|
+
onMouseUp={() => {
|
|
314
|
+
const sel = renderer.getSelection()
|
|
315
|
+
if (sel && sel.getSelectedText()) copySelectionToClipboard()
|
|
316
|
+
}}
|
|
317
|
+
>
|
|
318
|
+
<Header
|
|
319
|
+
modelId={props.modelId}
|
|
320
|
+
thinking={props.thinking}
|
|
321
|
+
contextTokens={props.contextTokens}
|
|
322
|
+
contextWindow={props.contextWindow}
|
|
323
|
+
queueCounts={props.queueCounts}
|
|
324
|
+
activityState={props.activityState}
|
|
325
|
+
retryStatus={props.retryStatus}
|
|
326
|
+
lspActive={props.lspActive}
|
|
327
|
+
spinnerFrame={spinnerFrame()}
|
|
328
|
+
lsp={props.lsp}
|
|
329
|
+
/>
|
|
330
|
+
<MessagePane
|
|
331
|
+
messages={props.messages}
|
|
332
|
+
toolBlocks={props.toolBlocks}
|
|
333
|
+
thinkingVisible={props.thinkingVisible}
|
|
334
|
+
diffWrapMode={props.diffWrapMode}
|
|
335
|
+
concealMarkdown={props.concealMarkdown}
|
|
336
|
+
isToolExpanded={isToolExpanded}
|
|
337
|
+
toggleToolExpanded={toggleToolExpanded}
|
|
338
|
+
isThinkingExpanded={isThinkingExpanded}
|
|
339
|
+
toggleThinkingExpanded={toggleThinkingExpanded}
|
|
340
|
+
onEditFile={handleEditFile}
|
|
341
|
+
/>
|
|
342
|
+
<Composer
|
|
343
|
+
theme={theme}
|
|
344
|
+
isBashMode={isBashMode}
|
|
345
|
+
showAutocomplete={showAutocomplete}
|
|
346
|
+
autocompleteItems={autocompleteItems}
|
|
347
|
+
autocompleteIndex={autocompleteIndex}
|
|
348
|
+
textareaRef={(ref) => {
|
|
349
|
+
textareaRef = ref
|
|
350
|
+
ref.focus()
|
|
351
|
+
}}
|
|
352
|
+
onContentChange={handleComposerContentChange}
|
|
353
|
+
onSubmit={() => {
|
|
354
|
+
if (!textareaRef) return
|
|
355
|
+
props.onSubmit(textareaRef.plainText, () => {
|
|
356
|
+
textareaRef?.clear()
|
|
357
|
+
setIsBashMode(false)
|
|
358
|
+
})
|
|
359
|
+
}}
|
|
360
|
+
onKeyDown={handleKeyDown}
|
|
361
|
+
terminalWidth={() => dimensions().width}
|
|
362
|
+
/>
|
|
363
|
+
<Footer branch={branch()} bashMode={isBashMode()} />
|
|
364
|
+
<ToastViewport toasts={toasts()} />
|
|
365
|
+
</box>
|
|
366
|
+
)
|
|
367
|
+
}
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import type { ToolBlock, UIMessage } from "../../../types.js"
|
|
2
|
+
import { MessageList } from "../../../components/MessageList.js"
|
|
3
|
+
|
|
4
|
+
export interface MessagePaneProps {
|
|
5
|
+
messages: UIMessage[]
|
|
6
|
+
toolBlocks: ToolBlock[]
|
|
7
|
+
thinkingVisible: boolean
|
|
8
|
+
diffWrapMode: "word" | "none"
|
|
9
|
+
concealMarkdown: boolean
|
|
10
|
+
isToolExpanded: (id: string) => boolean
|
|
11
|
+
toggleToolExpanded: (id: string) => void
|
|
12
|
+
isThinkingExpanded: (id: string) => boolean
|
|
13
|
+
toggleThinkingExpanded: (id: string) => void
|
|
14
|
+
onEditFile: (filePath: string, line?: number) => void
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export function MessagePane(props: MessagePaneProps) {
|
|
18
|
+
return (
|
|
19
|
+
<scrollbox stickyScroll stickyStart="bottom" flexGrow={props.messages.length > 0 ? 1 : 0} flexShrink={1}>
|
|
20
|
+
<MessageList
|
|
21
|
+
messages={props.messages}
|
|
22
|
+
toolBlocks={props.toolBlocks}
|
|
23
|
+
thinkingVisible={props.thinkingVisible}
|
|
24
|
+
diffWrapMode={props.diffWrapMode}
|
|
25
|
+
concealMarkdown={props.concealMarkdown}
|
|
26
|
+
isToolExpanded={props.isToolExpanded}
|
|
27
|
+
toggleToolExpanded={props.toggleToolExpanded}
|
|
28
|
+
isThinkingExpanded={props.isThinkingExpanded}
|
|
29
|
+
toggleThinkingExpanded={props.toggleThinkingExpanded}
|
|
30
|
+
onEditFile={props.onEditFile}
|
|
31
|
+
/>
|
|
32
|
+
</scrollbox>
|
|
33
|
+
)
|
|
34
|
+
}
|