herm-tui 1.0.0-dev.1
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/LICENSE +21 -0
- package/README.md +54 -0
- package/package.json +82 -0
- package/scripts/postinstall.ts +29 -0
- package/src/app/gateway.tsx +83 -0
- package/src/app/gatewayEvents.ts +203 -0
- package/src/app/launch.ts +41 -0
- package/src/app/skin.tsx +31 -0
- package/src/app/spawnHistory.ts +75 -0
- package/src/app/tabs.ts +23 -0
- package/src/app/turnReducer.ts +390 -0
- package/src/app/useAppKeys.ts +268 -0
- package/src/app/useAtRefPopover.ts +99 -0
- package/src/app/useInputHistory.ts +66 -0
- package/src/app/useSession.ts +102 -0
- package/src/app/useSlashCommands.ts +70 -0
- package/src/app/useSlashPopover.ts +48 -0
- package/src/app.tsx +917 -0
- package/src/commands/slash.ts +151 -0
- package/src/components/avatar/AnimatedAvatar.tsx +66 -0
- package/src/components/avatar/eikon.ts +144 -0
- package/src/components/avatar/states/error.ts +1155 -0
- package/src/components/avatar/states/idle.ts +1155 -0
- package/src/components/avatar/states/index.ts +30 -0
- package/src/components/avatar/states/listening.ts +1155 -0
- package/src/components/avatar/states/speaking.ts +1155 -0
- package/src/components/avatar/states/thinking.ts +1155 -0
- package/src/components/avatar/states/working.ts +1155 -0
- package/src/components/chat/AtRefPopover.tsx +54 -0
- package/src/components/chat/CodeBlock.tsx +67 -0
- package/src/components/chat/Composer.tsx +347 -0
- package/src/components/chat/DiffBlock.tsx +116 -0
- package/src/components/chat/ErrorBlock.tsx +70 -0
- package/src/components/chat/MediaChip.tsx +114 -0
- package/src/components/chat/MessageItem.tsx +282 -0
- package/src/components/chat/MessageList.tsx +114 -0
- package/src/components/chat/PromptCard.tsx +359 -0
- package/src/components/chat/SlashPopover.tsx +158 -0
- package/src/components/chat/ThoughtCloud.tsx +185 -0
- package/src/components/chat/TypingIndicator.tsx +25 -0
- package/src/components/chat/tool/Subagent.tsx +75 -0
- package/src/components/chat/tool/frame.tsx +69 -0
- package/src/components/chat/tool/index.tsx +65 -0
- package/src/components/chat/tool/preview.ts +57 -0
- package/src/components/sidebar/ContextGauge.tsx +102 -0
- package/src/components/sidebar/Sidebar.tsx +143 -0
- package/src/components/tabs/TabBar.tsx +50 -0
- package/src/components/ui/FileLink.tsx +52 -0
- package/src/config/index.ts +156 -0
- package/src/config/lane.ts +161 -0
- package/src/config/models.ts +95 -0
- package/src/config/rules.ts +80 -0
- package/src/config/schema.ts +308 -0
- package/src/dialogs/alert.tsx +52 -0
- package/src/dialogs/chafa.tsx +72 -0
- package/src/dialogs/confirm.tsx +58 -0
- package/src/dialogs/curator.tsx +153 -0
- package/src/dialogs/eikon-picker.tsx +95 -0
- package/src/dialogs/help.tsx +80 -0
- package/src/dialogs/history.tsx +92 -0
- package/src/dialogs/info.tsx +115 -0
- package/src/dialogs/keys.tsx +170 -0
- package/src/dialogs/logs.tsx +42 -0
- package/src/dialogs/message.tsx +38 -0
- package/src/dialogs/model-picker.tsx +123 -0
- package/src/dialogs/new-profile.tsx +69 -0
- package/src/dialogs/new-task.tsx +103 -0
- package/src/dialogs/profile.tsx +55 -0
- package/src/dialogs/rollback.tsx +190 -0
- package/src/dialogs/spawn-history.tsx +80 -0
- package/src/dialogs/text-prompt.tsx +68 -0
- package/src/dialogs/theme-picker.tsx +50 -0
- package/src/home/index.ts +23 -0
- package/src/home/store.ts +267 -0
- package/src/index.tsx +113 -0
- package/src/keys/catalog.ts +115 -0
- package/src/keys/chord.ts +125 -0
- package/src/keys/conflicts.ts +48 -0
- package/src/keys/context.tsx +112 -0
- package/src/keys/index.ts +5 -0
- package/src/keys/list.ts +94 -0
- package/src/keys/oc-compat.ts +87 -0
- package/src/tabs/Agents.tsx +607 -0
- package/src/tabs/Analytics.tsx +154 -0
- package/src/tabs/Chat.tsx +50 -0
- package/src/tabs/Config.tsx +605 -0
- package/src/tabs/Context.tsx +599 -0
- package/src/tabs/Cron.tsx +294 -0
- package/src/tabs/Env.tsx +227 -0
- package/src/tabs/Kanban.tsx +367 -0
- package/src/tabs/Memory.tsx +294 -0
- package/src/tabs/Sessions.tsx +786 -0
- package/src/tabs/Skills.tsx +507 -0
- package/src/tabs/Toolsets.tsx +266 -0
- package/src/theme/builtin.ts +78 -0
- package/src/theme/context.tsx +106 -0
- package/src/theme/index.ts +4 -0
- package/src/theme/resolve.ts +134 -0
- package/src/theme/syntax.ts +31 -0
- package/src/theme/themes/aura.json +69 -0
- package/src/theme/themes/ayu.json +80 -0
- package/src/theme/themes/carbonfox.json +248 -0
- package/src/theme/themes/catppuccin-frappe.json +233 -0
- package/src/theme/themes/catppuccin-macchiato.json +233 -0
- package/src/theme/themes/catppuccin.json +112 -0
- package/src/theme/themes/cobalt2.json +228 -0
- package/src/theme/themes/cursor.json +249 -0
- package/src/theme/themes/dracula.json +219 -0
- package/src/theme/themes/everforest.json +241 -0
- package/src/theme/themes/flexoki.json +237 -0
- package/src/theme/themes/github.json +233 -0
- package/src/theme/themes/gruvbox.json +242 -0
- package/src/theme/themes/kanagawa.json +77 -0
- package/src/theme/themes/lucent-orng.json +237 -0
- package/src/theme/themes/material.json +235 -0
- package/src/theme/themes/matrix.json +77 -0
- package/src/theme/themes/mercury.json +252 -0
- package/src/theme/themes/monokai.json +221 -0
- package/src/theme/themes/nightowl.json +221 -0
- package/src/theme/themes/nord.json +223 -0
- package/src/theme/themes/one-dark.json +84 -0
- package/src/theme/themes/opencode.json +245 -0
- package/src/theme/themes/orng.json +249 -0
- package/src/theme/themes/osaka-jade.json +93 -0
- package/src/theme/themes/palenight.json +222 -0
- package/src/theme/themes/rosepine.json +234 -0
- package/src/theme/themes/solarized.json +223 -0
- package/src/theme/themes/synthwave84.json +226 -0
- package/src/theme/themes/tokyonight.json +243 -0
- package/src/theme/themes/vercel.json +245 -0
- package/src/theme/themes/vesper.json +218 -0
- package/src/theme/themes/zenburn.json +223 -0
- package/src/theme/types.ts +119 -0
- package/src/types/message.ts +97 -0
- package/src/ui/ChafaImage.tsx +64 -0
- package/src/ui/Splash.tsx +118 -0
- package/src/ui/borders.ts +28 -0
- package/src/ui/command.tsx +104 -0
- package/src/ui/dialog-select.tsx +164 -0
- package/src/ui/dialog.tsx +102 -0
- package/src/ui/fmt.ts +82 -0
- package/src/ui/kv.tsx +28 -0
- package/src/ui/shell.tsx +45 -0
- package/src/ui/spinner.tsx +59 -0
- package/src/ui/splash-art.ts +123 -0
- package/src/ui/table.tsx +117 -0
- package/src/ui/ticker.tsx +90 -0
- package/src/ui/toast.tsx +130 -0
- package/src/utils/categorical.ts +77 -0
- package/src/utils/chafa.ts +173 -0
- package/src/utils/clipboard.ts +67 -0
- package/src/utils/context-segments.ts +317 -0
- package/src/utils/control.ts +495 -0
- package/src/utils/drop.ts +25 -0
- package/src/utils/editor.ts +33 -0
- package/src/utils/fuzzy.ts +45 -0
- package/src/utils/gateway-client.ts +253 -0
- package/src/utils/gateway-types.ts +282 -0
- package/src/utils/git.ts +57 -0
- package/src/utils/hermes-analytics.ts +134 -0
- package/src/utils/hermes-home.ts +821 -0
- package/src/utils/hermes-kanban.ts +154 -0
- package/src/utils/hermes-profiles.ts +217 -0
- package/src/utils/interpolate.ts +31 -0
- package/src/utils/math-unicode.ts +818 -0
- package/src/utils/memory-activity.ts +140 -0
- package/src/utils/open-file.ts +13 -0
- package/src/utils/paths.ts +52 -0
- package/src/utils/perf.ts +235 -0
- package/src/utils/preferences.ts +150 -0
- package/src/utils/sessions-db.ts +396 -0
- package/src/utils/subagent-tree.ts +146 -0
- package/src/utils/terminal-reset.ts +129 -0
- package/src/utils/tips.ts +67 -0
- package/src/utils/tokens.ts +87 -0
|
@@ -0,0 +1,170 @@
|
|
|
1
|
+
// /keys rebind dialog — list every catalog action grouped by scope, show
|
|
2
|
+
// current chord + override marker + inline conflict warning. Enter opens
|
|
3
|
+
// a TextPrompt for the chord spec (e.g. "ctrl+l", "<leader>m") — typing
|
|
4
|
+
// the spec rather than capturing the raw keystroke sidesteps the global
|
|
5
|
+
// useKeyboard ordering problem (useAppKeys would see the captured key
|
|
6
|
+
// first and act on it). 'r' resets the selected row to its default.
|
|
7
|
+
|
|
8
|
+
import { useState, useMemo } from "react"
|
|
9
|
+
import { VBAR } from "../ui/table"
|
|
10
|
+
import { useKeyboard } from "@opentui/react"
|
|
11
|
+
import { useTheme } from "../theme"
|
|
12
|
+
import * as prefs from "../utils/preferences"
|
|
13
|
+
import {
|
|
14
|
+
useKeys, DEFAULTS, conflictsWith, parse,
|
|
15
|
+
type ActionId, type Scope, type Chord,
|
|
16
|
+
} from "../keys"
|
|
17
|
+
import { print as chordPrint } from "../keys/chord"
|
|
18
|
+
import type { DialogContext } from "../ui/dialog"
|
|
19
|
+
import { useToast } from "../ui/toast"
|
|
20
|
+
import { openTextPrompt } from "./text-prompt"
|
|
21
|
+
import { openConfirm } from "./confirm"
|
|
22
|
+
import { loadOcKeybinds } from "../keys/oc-compat"
|
|
23
|
+
|
|
24
|
+
type Group = { title: string; scope: Scope }
|
|
25
|
+
|
|
26
|
+
const GROUPS: ReadonlyArray<Group> = [
|
|
27
|
+
{ title: "Global", scope: "global" },
|
|
28
|
+
{ title: "Composer", scope: "composer" },
|
|
29
|
+
{ title: "Lists", scope: "list" },
|
|
30
|
+
{ title: "Dialogs", scope: "dialog" },
|
|
31
|
+
{ title: "Sessions", scope: "sessions" },
|
|
32
|
+
{ title: "Agents", scope: "agents" },
|
|
33
|
+
{ title: "Config", scope: "config" },
|
|
34
|
+
]
|
|
35
|
+
|
|
36
|
+
type Row =
|
|
37
|
+
| { type: "header"; title: string }
|
|
38
|
+
| { type: "action"; id: ActionId; desc: string; chord: ReadonlyArray<Chord>; override: boolean }
|
|
39
|
+
|
|
40
|
+
const KeysDialog = (props: { dialog: DialogContext }) => {
|
|
41
|
+
const theme = useTheme().theme
|
|
42
|
+
const keys = useKeys()
|
|
43
|
+
const toast = useToast()
|
|
44
|
+
const overrides = prefs.get("keys") ?? {}
|
|
45
|
+
|
|
46
|
+
const rows = useMemo<Row[]>(() => GROUPS.flatMap(g => {
|
|
47
|
+
const entries = keys.all(g.scope).filter(e => e.id !== "leader")
|
|
48
|
+
if (entries.length === 0) return []
|
|
49
|
+
return [
|
|
50
|
+
{ type: "header" as const, title: g.title },
|
|
51
|
+
...entries.map(e => ({
|
|
52
|
+
type: "action" as const,
|
|
53
|
+
id: e.id, desc: e.desc, chord: e.chord,
|
|
54
|
+
override: overrides[e.id] !== undefined,
|
|
55
|
+
})),
|
|
56
|
+
]
|
|
57
|
+
}), [keys, overrides])
|
|
58
|
+
|
|
59
|
+
const actionRows = rows.map((r, i) => ({ r, i })).filter(x => x.r.type === "action")
|
|
60
|
+
const [sel, setSel] = useState(0)
|
|
61
|
+
|
|
62
|
+
const cur = actionRows[sel]?.r as Extract<Row, { type: "action" }> | undefined
|
|
63
|
+
const curConflicts = cur ? conflictsWith(keys.table, cur.id) : []
|
|
64
|
+
|
|
65
|
+
const write = (id: ActionId, value: string | undefined) => {
|
|
66
|
+
const next = { ...(prefs.get("keys") ?? {}) }
|
|
67
|
+
if (value === undefined) delete next[id]
|
|
68
|
+
else next[id] = value
|
|
69
|
+
prefs.set("keys", next)
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
const rebind = (id: ActionId) => {
|
|
73
|
+
const now = overrides[id] ?? DEFAULTS[id].chord
|
|
74
|
+
void openTextPrompt(props.dialog, {
|
|
75
|
+
title: `Rebind ${id}`,
|
|
76
|
+
label: "Chord (e.g. ctrl+k, <leader>m, shift+return; empty = unbind)",
|
|
77
|
+
initial: now,
|
|
78
|
+
}).then(v => {
|
|
79
|
+
// TextPrompt's dialog.clear() replaced us; remount either way.
|
|
80
|
+
openKeys(props.dialog)
|
|
81
|
+
if (v === null) return
|
|
82
|
+
const parsed = parse(v)
|
|
83
|
+
write(id, parsed.length === 0 ? "none" : v)
|
|
84
|
+
})
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
const importOc = () => {
|
|
88
|
+
const r = loadOcKeybinds()
|
|
89
|
+
if (r.sources.length === 0)
|
|
90
|
+
return toast.show({ variant: "info", message: "No opencode tui.json found" })
|
|
91
|
+
const n = Object.keys(r.overrides).length
|
|
92
|
+
void openConfirm(props.dialog, {
|
|
93
|
+
title: `Import ${n} keybind${n === 1 ? "" : "s"} from opencode?`,
|
|
94
|
+
body: `${r.sources.map(s => `· ${s}`).join("\n")}\n\n${n} mapped · ${r.skipped.length} skipped (no herm equivalent)${r.skipped.length ? `:\n${r.skipped.slice(0, 8).join(", ")}${r.skipped.length > 8 ? ", …" : ""}` : ""}`,
|
|
95
|
+
yes: "import",
|
|
96
|
+
}).then(ok => {
|
|
97
|
+
openKeys(props.dialog)
|
|
98
|
+
if (!ok) return
|
|
99
|
+
prefs.set("keys", { ...(prefs.get("keys") ?? {}), ...r.overrides })
|
|
100
|
+
toast.show({ variant: "success",
|
|
101
|
+
message: `Imported ${n} · skipped ${r.skipped.length}` })
|
|
102
|
+
})
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
useKeyboard((key) => {
|
|
106
|
+
if (key.name === "up") return setSel(s => Math.max(0, s - 1))
|
|
107
|
+
if (key.name === "down") return setSel(s => Math.min(actionRows.length - 1, s + 1))
|
|
108
|
+
if (key.name === "return" && cur) return rebind(cur.id)
|
|
109
|
+
if (key.name === "r" && !key.ctrl && cur?.override) { write(cur.id, undefined); return }
|
|
110
|
+
if (key.name === "o" && !key.ctrl) return importOc()
|
|
111
|
+
})
|
|
112
|
+
|
|
113
|
+
return (
|
|
114
|
+
<box flexDirection="column" width={78}>
|
|
115
|
+
<box height={1} flexDirection="row">
|
|
116
|
+
<box flexGrow={1}><text fg={theme.text}><strong>Keybindings</strong></text></box>
|
|
117
|
+
<text fg={theme.textMuted}>{`leader = ${keys.print("leader")}`}</text>
|
|
118
|
+
</box>
|
|
119
|
+
<box height={1} />
|
|
120
|
+
<scrollbox scrollY maxHeight={22} verticalScrollbarOptions={VBAR}>
|
|
121
|
+
<box flexDirection="column" width="100%">
|
|
122
|
+
{rows.map((r, i) => {
|
|
123
|
+
if (r.type === "header") return (
|
|
124
|
+
<box key={`h-${r.title}`} height={1} marginTop={i > 0 ? 1 : 0}>
|
|
125
|
+
<text fg={theme.primary}><strong>{r.title}</strong></text>
|
|
126
|
+
</box>
|
|
127
|
+
)
|
|
128
|
+
const ai = actionRows.findIndex(x => x.i === i)
|
|
129
|
+
const on = ai === sel
|
|
130
|
+
const conf = conflictsWith(keys.table, r.id)
|
|
131
|
+
return (
|
|
132
|
+
<box key={r.id} height={1} flexDirection="row"
|
|
133
|
+
backgroundColor={on ? theme.backgroundElement : undefined}
|
|
134
|
+
onMouseOver={() => setSel(ai)}
|
|
135
|
+
onMouseDown={() => { setSel(ai); rebind(r.id) }}>
|
|
136
|
+
<box width={2} flexShrink={0}>
|
|
137
|
+
<text fg={on ? theme.primary : theme.text}>{on ? "▸ " : " "}</text>
|
|
138
|
+
</box>
|
|
139
|
+
<box width={16} flexShrink={0} height={1} overflow="hidden">
|
|
140
|
+
<text fg={on ? theme.accent : theme.text}>
|
|
141
|
+
{chordPrint(r.chord, keys.print("leader")) || "—"}
|
|
142
|
+
</text>
|
|
143
|
+
</box>
|
|
144
|
+
<box flexGrow={1} minWidth={0} height={1} overflow="hidden">
|
|
145
|
+
<text fg={theme.textMuted}>{r.desc}</text>
|
|
146
|
+
</box>
|
|
147
|
+
<box width={5} flexShrink={0} flexDirection="row" justifyContent="flex-end">
|
|
148
|
+
<text>
|
|
149
|
+
{r.override ? <span fg={theme.info}>{"· "}</span> : null}
|
|
150
|
+
{conf.length > 0 ? <span fg={theme.warning}>⚠</span> : null}
|
|
151
|
+
</text>
|
|
152
|
+
</box>
|
|
153
|
+
</box>
|
|
154
|
+
)
|
|
155
|
+
})}
|
|
156
|
+
</box>
|
|
157
|
+
</scrollbox>
|
|
158
|
+
<box height={1} />
|
|
159
|
+
<box height={1}>
|
|
160
|
+
{curConflicts.length > 0
|
|
161
|
+
? <text fg={theme.warning}>{`⚠ shares ${keys.print(cur!.id)} with: ${curConflicts.join(", ")}`}</text>
|
|
162
|
+
: <text fg={theme.textMuted}>{`↑↓ select Enter rebind${cur?.override ? " · r reset" : ""} · o import opencode · esc close · · = overridden`}</text>}
|
|
163
|
+
</box>
|
|
164
|
+
</box>
|
|
165
|
+
)
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
export function openKeys(dialog: DialogContext) {
|
|
169
|
+
dialog.replace(<KeysDialog dialog={dialog} />)
|
|
170
|
+
}
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
// Gateway subprocess stderr tail — everything GatewayClient.log() captured
|
|
2
|
+
// (stderr lines, protocol errors, startup-timeout markers). The ring
|
|
3
|
+
// buffer holds the last ~200 lines regardless of what the transcript
|
|
4
|
+
// chose to surface.
|
|
5
|
+
|
|
6
|
+
import { useTheme } from "../theme"
|
|
7
|
+
import { useGateway } from "../app/gateway"
|
|
8
|
+
import { useDialog } from "../ui/dialog"
|
|
9
|
+
|
|
10
|
+
const ERRLIKE = /error|fail|traceback|exception|\b[45]\d\d\b|refused|denied|unauthori/i
|
|
11
|
+
|
|
12
|
+
const LogsDialog = () => {
|
|
13
|
+
const theme = useTheme().theme
|
|
14
|
+
const gw = useGateway()
|
|
15
|
+
const lines = gw.tail(200).split("\n").filter(Boolean)
|
|
16
|
+
|
|
17
|
+
return (
|
|
18
|
+
<box flexDirection="column" width={110} height={Math.min(34, Math.max(8, lines.length + 5))}>
|
|
19
|
+
<box height={1}><text fg={theme.primary}><strong>Gateway Logs</strong></text></box>
|
|
20
|
+
<box height={1}><text fg={theme.textMuted}>{lines.length} lines · stderr + protocol · Esc to close</text></box>
|
|
21
|
+
<box height={1} />
|
|
22
|
+
{lines.length === 0 ? (
|
|
23
|
+
<box height={1}><text fg={theme.textMuted}>No log output captured.</text></box>
|
|
24
|
+
) : (
|
|
25
|
+
<scrollbox scrollY stickyScroll stickyStart="bottom" flexGrow={1}>
|
|
26
|
+
<box flexDirection="column">
|
|
27
|
+
{lines.map((l, i) => (
|
|
28
|
+
<box key={i} height={1}>
|
|
29
|
+
<text fg={ERRLIKE.test(l) ? theme.error : theme.textMuted}>
|
|
30
|
+
{l.length > 106 ? l.slice(0, 105) + "…" : l}
|
|
31
|
+
</text>
|
|
32
|
+
</box>
|
|
33
|
+
))}
|
|
34
|
+
</box>
|
|
35
|
+
</scrollbox>
|
|
36
|
+
)}
|
|
37
|
+
</box>
|
|
38
|
+
)
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export const openLogs = (dialog: ReturnType<typeof useDialog>) =>
|
|
42
|
+
dialog.replace(<LogsDialog />)
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
// Per-message action menu — oc routes/session/dialog-message.tsx.
|
|
2
|
+
// Opened by clicking a user message. Copy is local; Rewind and Fork
|
|
3
|
+
// delegate to callbacks owned by app.tsx (they need turn state +
|
|
4
|
+
// gateway + composer).
|
|
5
|
+
|
|
6
|
+
import { DialogSelect } from "../ui/dialog-select"
|
|
7
|
+
import type { DialogContext } from "../ui/dialog"
|
|
8
|
+
import type { Message } from "../types/message"
|
|
9
|
+
import { copy } from "../utils/clipboard"
|
|
10
|
+
|
|
11
|
+
export type MessageOps = {
|
|
12
|
+
rewind: (m: Message) => void
|
|
13
|
+
fork: (m: Message) => void
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export function openMessage(dialog: DialogContext, m: Message, ops: MessageOps) {
|
|
17
|
+
const text = m.parts
|
|
18
|
+
.filter(p => p.type === "text")
|
|
19
|
+
.map(p => p.content)
|
|
20
|
+
.join("")
|
|
21
|
+
|
|
22
|
+
dialog.replace(
|
|
23
|
+
<DialogSelect
|
|
24
|
+
title="Message Actions"
|
|
25
|
+
options={[
|
|
26
|
+
{ title: "Copy", value: "copy", description: "message text to clipboard" },
|
|
27
|
+
{ title: "Rewind here", value: "rewind", description: "undo back to this turn (destructive)" },
|
|
28
|
+
{ title: "Fork here", value: "fork", description: "branch a new session at this point" },
|
|
29
|
+
]}
|
|
30
|
+
onSelect={(o) => {
|
|
31
|
+
dialog.clear()
|
|
32
|
+
if (o.value === "copy") return void copy(text)
|
|
33
|
+
if (o.value === "rewind") return ops.rewind(m)
|
|
34
|
+
if (o.value === "fork") return ops.fork(m)
|
|
35
|
+
}}
|
|
36
|
+
/>,
|
|
37
|
+
)
|
|
38
|
+
}
|
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
// Pick provider → model. Default scope is the *current session* (the
|
|
2
|
+
// gateway applies the switch to the live agent when `session_id` is
|
|
3
|
+
// passed); Tab toggles to global persist. The gateway's `config.set`
|
|
4
|
+
// accepts a single space-separated arg string with `--provider` /
|
|
5
|
+
// `--global` flags (same grammar as the `/model` slash command) and
|
|
6
|
+
// routes through `_apply_model_switch`, so we send one request rather
|
|
7
|
+
// than a provider/model pair.
|
|
8
|
+
|
|
9
|
+
import { useEffect, useState, useCallback } from "react"
|
|
10
|
+
import { useDialog } from "../ui/dialog"
|
|
11
|
+
import { DialogSelect, type SelectOption } from "../ui/dialog-select"
|
|
12
|
+
import { useTheme } from "../theme"
|
|
13
|
+
import { useToast } from "../ui/toast"
|
|
14
|
+
import type { Gateway } from "../app/gateway"
|
|
15
|
+
import type { ConfigSetResponse, ModelOptionsResponse } from "../utils/gateway-types"
|
|
16
|
+
|
|
17
|
+
type Step = "provider" | "model"
|
|
18
|
+
|
|
19
|
+
type Props = {
|
|
20
|
+
gw: Gateway
|
|
21
|
+
/** Override the default "switch this session / global" apply. When
|
|
22
|
+
* set, the scope toggle is hidden and the caller owns the write. */
|
|
23
|
+
onApply?: (provider: string, model: string) => Promise<void>
|
|
24
|
+
title?: string
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
const ModelPickerDialog = (props: Props) => {
|
|
28
|
+
const dialog = useDialog()
|
|
29
|
+
const toast = useToast()
|
|
30
|
+
const theme = useTheme().theme
|
|
31
|
+
const [data, setData] = useState<ModelOptionsResponse | null>(null)
|
|
32
|
+
const [step, setStep] = useState<Step>("provider")
|
|
33
|
+
const [provider, setProvider] = useState<string | null>(null)
|
|
34
|
+
const [global, setGlobal] = useState(false)
|
|
35
|
+
|
|
36
|
+
useEffect(() => {
|
|
37
|
+
props.gw.request<ModelOptionsResponse>("model.options")
|
|
38
|
+
.then(setData)
|
|
39
|
+
.catch(() => setData({ providers: [] }))
|
|
40
|
+
}, [props.gw])
|
|
41
|
+
|
|
42
|
+
const apply = useCallback((model: string, prov: string) => {
|
|
43
|
+
if (props.onApply) return void props.onApply(prov, model)
|
|
44
|
+
.catch((e: Error) => toast.show({ variant: "error", message: e.message }))
|
|
45
|
+
const value = `${model} --provider ${prov}${global ? " --global" : ""}`
|
|
46
|
+
props.gw.request<ConfigSetResponse>("config.set", global
|
|
47
|
+
? { key: "model", value, session_id: undefined }
|
|
48
|
+
: { key: "model", value })
|
|
49
|
+
.then(r => {
|
|
50
|
+
toast.show({ variant: "success", message: `model → ${r.value ?? model}${global ? " (global)" : ""}` })
|
|
51
|
+
if (r.warning) toast.show({ variant: "warning", message: r.warning })
|
|
52
|
+
})
|
|
53
|
+
.catch((e: Error) => toast.show({ variant: "error", message: e.message }))
|
|
54
|
+
}, [props.gw, props.onApply, global, toast])
|
|
55
|
+
|
|
56
|
+
const onKey = useCallback((k: { name: string }) => {
|
|
57
|
+
if (k.name === "tab" && !props.onApply) { setGlobal(g => !g); return true }
|
|
58
|
+
if (k.name === "left" && step === "model") { setStep("provider"); return true }
|
|
59
|
+
return false
|
|
60
|
+
}, [step, props.onApply])
|
|
61
|
+
|
|
62
|
+
const footer = props.onApply
|
|
63
|
+
? <text fg={theme.textMuted}>{step === "model" ? "←: providers" : " "}</text>
|
|
64
|
+
: (
|
|
65
|
+
<text fg={theme.textMuted}>
|
|
66
|
+
<span>Scope: </span>
|
|
67
|
+
<span fg={global ? theme.warning : theme.accent}>
|
|
68
|
+
{global ? "global (persists to config)" : "this session"}
|
|
69
|
+
</span>
|
|
70
|
+
<span> · Tab: toggle{step === "model" ? " · ←: providers" : ""}</span>
|
|
71
|
+
</text>
|
|
72
|
+
)
|
|
73
|
+
|
|
74
|
+
if (!data) return <box width={50} padding={1}><text>Loading models…</text></box>
|
|
75
|
+
|
|
76
|
+
if (step === "provider") {
|
|
77
|
+
const options: SelectOption[] = (data.providers ?? []).map(p => ({
|
|
78
|
+
title: p.name,
|
|
79
|
+
value: p.slug,
|
|
80
|
+
description: p.total_models ? `${p.total_models} models` : undefined,
|
|
81
|
+
category: p.is_current ? "Current" : "Available",
|
|
82
|
+
}))
|
|
83
|
+
return (
|
|
84
|
+
<DialogSelect
|
|
85
|
+
title={props.title ?? "Switch Provider"}
|
|
86
|
+
options={options}
|
|
87
|
+
current={data.provider}
|
|
88
|
+
onSelect={(o) => { setProvider(o.value); setStep("model") }}
|
|
89
|
+
onKey={onKey}
|
|
90
|
+
placeholder="Search providers..."
|
|
91
|
+
footer={footer}
|
|
92
|
+
/>
|
|
93
|
+
)
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
const p = data.providers?.find(pp => pp.slug === provider)
|
|
97
|
+
const options: SelectOption[] = (p?.models ?? []).map(m => ({
|
|
98
|
+
title: m,
|
|
99
|
+
value: m,
|
|
100
|
+
}))
|
|
101
|
+
|
|
102
|
+
return (
|
|
103
|
+
<DialogSelect
|
|
104
|
+
title={props.title ? `${props.title} · ${p?.name ?? provider}` : `Switch Model (${p?.name ?? provider})`}
|
|
105
|
+
options={options}
|
|
106
|
+
current={data.model}
|
|
107
|
+
onSelect={(o) => {
|
|
108
|
+
if (provider) apply(o.value, provider)
|
|
109
|
+
dialog.clear()
|
|
110
|
+
}}
|
|
111
|
+
onKey={onKey}
|
|
112
|
+
placeholder="Search models..."
|
|
113
|
+
footer={footer}
|
|
114
|
+
/>
|
|
115
|
+
)
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
export const openModelPicker = (
|
|
119
|
+
dialog: ReturnType<typeof useDialog>, gw: Gateway,
|
|
120
|
+
opts?: { title?: string; onApply?: (provider: string, model: string) => Promise<void> },
|
|
121
|
+
) => {
|
|
122
|
+
dialog.replace(<ModelPickerDialog gw={gw} title={opts?.title} onApply={opts?.onApply} />)
|
|
123
|
+
}
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
import { useState } from "react"
|
|
2
|
+
import { useKeyboard } from "@opentui/react"
|
|
3
|
+
import { useTheme } from "../theme"
|
|
4
|
+
import { validateName } from "../utils/hermes-profiles"
|
|
5
|
+
import type { DialogContext } from "../ui/dialog"
|
|
6
|
+
|
|
7
|
+
type Result = { name: string; cloneFrom: string | null; alias: boolean }
|
|
8
|
+
|
|
9
|
+
export function openCreateProfile(dialog: DialogContext, opts: { existing: string[] }): Promise<Result | null> {
|
|
10
|
+
return new Promise(resolve => {
|
|
11
|
+
const done = (r: Result | null) => { dialog.clear(); resolve(r) }
|
|
12
|
+
dialog.replace(<Form existing={opts.existing} done={done} />)
|
|
13
|
+
})
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
const Form = ({ existing, done }: { existing: string[]; done: (r: Result | null) => void }) => {
|
|
17
|
+
const theme = useTheme().theme
|
|
18
|
+
const [name, setName] = useState("")
|
|
19
|
+
const [cloneIdx, setCloneIdx] = useState(0)
|
|
20
|
+
const [alias, setAlias] = useState(true)
|
|
21
|
+
const options = ["(fresh)", ...existing]
|
|
22
|
+
const err = name ? validateName(name, existing) : null
|
|
23
|
+
const valid = !!name && !err
|
|
24
|
+
|
|
25
|
+
useKeyboard((key) => {
|
|
26
|
+
if (key.name === "escape") return done(null)
|
|
27
|
+
if (key.name === "return") {
|
|
28
|
+
if (!valid) return
|
|
29
|
+
return done({ name, cloneFrom: cloneIdx === 0 ? null : options[cloneIdx], alias })
|
|
30
|
+
}
|
|
31
|
+
if (key.name === "up") return setCloneIdx(i => Math.max(0, i - 1))
|
|
32
|
+
if (key.name === "down") return setCloneIdx(i => Math.min(options.length - 1, i + 1))
|
|
33
|
+
if (key.name === "tab") return setAlias(a => !a)
|
|
34
|
+
if (key.name === "backspace") return setName(n => n.slice(0, -1))
|
|
35
|
+
if (key.raw && key.raw.length === 1 && /[a-z0-9_-]/.test(key.raw))
|
|
36
|
+
return setName(n => n + key.raw)
|
|
37
|
+
})
|
|
38
|
+
|
|
39
|
+
return (
|
|
40
|
+
<box flexDirection="column" width={54}>
|
|
41
|
+
<box height={1}><text fg={theme.primary}><strong>New Profile</strong></text></box>
|
|
42
|
+
<box height={1} />
|
|
43
|
+
<box height={1} flexDirection="row">
|
|
44
|
+
<box width={11}><text fg={theme.textMuted}>Name</text></box>
|
|
45
|
+
<text>
|
|
46
|
+
<span fg={valid || !name ? theme.text : theme.error}>{name}</span>
|
|
47
|
+
<span fg={theme.accent}>█</span>
|
|
48
|
+
</text>
|
|
49
|
+
</box>
|
|
50
|
+
<box height={1}><text fg={theme.textMuted}> a-z 0-9 _ - · lowercase</text></box>
|
|
51
|
+
<box height={1} />
|
|
52
|
+
<box height={1}><text fg={theme.textMuted}>Clone from (↑↓)</text></box>
|
|
53
|
+
{options.map((o, i) => (
|
|
54
|
+
<box key={o} height={1}>
|
|
55
|
+
<text fg={i === cloneIdx ? theme.accent : theme.text}>
|
|
56
|
+
{i === cloneIdx ? "▸ " : " "}{o}
|
|
57
|
+
</text>
|
|
58
|
+
</box>
|
|
59
|
+
))}
|
|
60
|
+
<box height={1} />
|
|
61
|
+
<box height={1}><text fg={theme.textMuted}>
|
|
62
|
+
{`[Tab] shell alias: ${alias ? "yes" : "no"}`}
|
|
63
|
+
</text></box>
|
|
64
|
+
<box height={1}><text fg={theme.textMuted}>
|
|
65
|
+
{valid ? "Enter create · Esc cancel" : err ?? "type a name"}
|
|
66
|
+
</text></box>
|
|
67
|
+
</box>
|
|
68
|
+
)
|
|
69
|
+
}
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
// Create/edit a kanban task. Tab cycles fields; ↑↓ pick assignee or
|
|
2
|
+
// bump priority depending on focused field; Enter submits when title
|
|
3
|
+
// is non-empty. Body is single-line here — longer specs go in as a
|
|
4
|
+
// follow-up comment (c) from the board.
|
|
5
|
+
|
|
6
|
+
import { useState } from "react"
|
|
7
|
+
import { useKeyboard } from "@opentui/react"
|
|
8
|
+
import { useTheme } from "../theme"
|
|
9
|
+
import type { DialogContext } from "../ui/dialog"
|
|
10
|
+
|
|
11
|
+
export type Draft = {
|
|
12
|
+
title: string; body: string; assignee: string | null
|
|
13
|
+
priority: number; parent: string | null
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
type Field = "title" | "body" | "assignee" | "priority"
|
|
17
|
+
const ORDER: Field[] = ["title", "body", "assignee", "priority"]
|
|
18
|
+
|
|
19
|
+
export function openCreateTask(
|
|
20
|
+
dialog: DialogContext,
|
|
21
|
+
opts: { assignees: string[]; parent?: { id: string; title: string } },
|
|
22
|
+
): Promise<Draft | null> {
|
|
23
|
+
return new Promise(resolve => {
|
|
24
|
+
const done = (r: Draft | null) => { dialog.clear(); resolve(r) }
|
|
25
|
+
dialog.replace(<Form pool={opts.assignees} parent={opts.parent} done={done} />)
|
|
26
|
+
})
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
const Form = (p: {
|
|
30
|
+
pool: string[]; parent?: { id: string; title: string }; done: (r: Draft | null) => void
|
|
31
|
+
}) => {
|
|
32
|
+
const theme = useTheme().theme
|
|
33
|
+
const pool = ["(unassigned)", ...p.pool]
|
|
34
|
+
const [field, setField] = useState<Field>("title")
|
|
35
|
+
const [title, setTitle] = useState("")
|
|
36
|
+
const [body, setBody] = useState("")
|
|
37
|
+
const [who, setWho] = useState(0)
|
|
38
|
+
const [pri, setPri] = useState(0)
|
|
39
|
+
const valid = title.trim().length > 0
|
|
40
|
+
|
|
41
|
+
const edit = (fn: (s: string) => string) =>
|
|
42
|
+
field === "title" ? setTitle(fn) : setBody(fn)
|
|
43
|
+
|
|
44
|
+
useKeyboard((key) => {
|
|
45
|
+
if (key.name === "escape") return p.done(null)
|
|
46
|
+
if (key.name === "return") {
|
|
47
|
+
if (!valid) return
|
|
48
|
+
return p.done({
|
|
49
|
+
title: title.trim(), body: body.trim(),
|
|
50
|
+
assignee: who === 0 ? null : pool[who],
|
|
51
|
+
priority: pri, parent: p.parent?.id ?? null,
|
|
52
|
+
})
|
|
53
|
+
}
|
|
54
|
+
if (key.name === "tab") {
|
|
55
|
+
const i = ORDER.indexOf(field)
|
|
56
|
+
return setField(ORDER[(i + (key.shift ? ORDER.length - 1 : 1)) % ORDER.length])
|
|
57
|
+
}
|
|
58
|
+
if (key.name === "up" || key.name === "down") {
|
|
59
|
+
const d = key.name === "up" ? -1 : 1
|
|
60
|
+
if (field === "priority") return setPri(n => Math.max(0, Math.min(9, n + d)))
|
|
61
|
+
return setWho(i => (i + d + pool.length) % pool.length)
|
|
62
|
+
}
|
|
63
|
+
if (field === "title" || field === "body") {
|
|
64
|
+
if (key.name === "backspace") return edit(s => s.slice(0, -1))
|
|
65
|
+
if (key.ctrl && key.name === "u") return edit(() => "")
|
|
66
|
+
if (!key.ctrl && !key.meta && key.raw && key.raw.length === 1 && key.raw >= " ")
|
|
67
|
+
return edit(s => s + key.raw)
|
|
68
|
+
}
|
|
69
|
+
})
|
|
70
|
+
|
|
71
|
+
const row = (f: Field, label: string, val: string) => (
|
|
72
|
+
<box height={1} flexDirection="row">
|
|
73
|
+
<box width={11}><text fg={field === f ? theme.accent : theme.textMuted}>
|
|
74
|
+
{field === f ? "▸ " : " "}{label}
|
|
75
|
+
</text></box>
|
|
76
|
+
<box flexGrow={1} minWidth={0} height={1} overflow="hidden">
|
|
77
|
+
<text>
|
|
78
|
+
<span fg={theme.text}>{val}</span>
|
|
79
|
+
{field === f && (f === "title" || f === "body")
|
|
80
|
+
? <span fg={theme.accent}>█</span> : null}
|
|
81
|
+
</text>
|
|
82
|
+
</box>
|
|
83
|
+
</box>
|
|
84
|
+
)
|
|
85
|
+
|
|
86
|
+
return (
|
|
87
|
+
<box flexDirection="column" width={64}>
|
|
88
|
+
<box height={1}><text fg={theme.primary}>
|
|
89
|
+
<strong>{p.parent ? `New Task · child of ${p.parent.id}` : "New Task"}</strong>
|
|
90
|
+
</text></box>
|
|
91
|
+
{p.parent ? <box height={1}><text fg={theme.textMuted}> {p.parent.title}</text></box> : null}
|
|
92
|
+
<box height={1} />
|
|
93
|
+
{row("title", "Title", title)}
|
|
94
|
+
{row("body", "Body", body || "—")}
|
|
95
|
+
{row("assignee", "Assignee", pool[who])}
|
|
96
|
+
{row("priority", "Priority", pri ? `P${pri}` : "—")}
|
|
97
|
+
<box height={1} />
|
|
98
|
+
<box height={1}><text fg={theme.textMuted}>
|
|
99
|
+
{valid ? "Enter create · Tab field · ↑↓ pick · Esc cancel" : "type a title · Tab field"}
|
|
100
|
+
</text></box>
|
|
101
|
+
</box>
|
|
102
|
+
)
|
|
103
|
+
}
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
// Per-profile action menu. All mutations route through the hermes CLI
|
|
2
|
+
// via `shell.exec` so herm doesn't duplicate validation/cleanup logic.
|
|
3
|
+
// "Open …" actions use the OS handler (openFile) rather than an
|
|
4
|
+
// in-TUI editor — SOUL.md and config.yaml are multi-hundred-line
|
|
5
|
+
// files, not composer-sized inputs.
|
|
6
|
+
|
|
7
|
+
import { DialogSelect, type SelectOption } from "../ui/dialog-select"
|
|
8
|
+
import type { DialogContext } from "../ui/dialog"
|
|
9
|
+
import type { ProfileInfo } from "../utils/hermes-profiles"
|
|
10
|
+
import { openFile } from "../utils/open-file"
|
|
11
|
+
|
|
12
|
+
export type ProfileOps = {
|
|
13
|
+
sticky: (p: ProfileInfo) => void
|
|
14
|
+
unsticky: () => void
|
|
15
|
+
export: (p: ProfileInfo) => void
|
|
16
|
+
remove: (p: ProfileInfo) => void
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export function openProfileMenu(dialog: DialogContext, p: ProfileInfo, ops: ProfileOps) {
|
|
20
|
+
const opts: SelectOption[] = [
|
|
21
|
+
{ category: "Open", value: "soul", title: "SOUL.md", description: "edit persona/system prompt" },
|
|
22
|
+
{ category: "Open", value: "config", title: "config.yaml", description: "model, provider, toolsets" },
|
|
23
|
+
...(p.has_env
|
|
24
|
+
? [{ category: "Open", value: "env", title: ".env", description: "API keys + secrets" }] : []),
|
|
25
|
+
{ category: "Open", value: "dir", title: "Directory", description: p.path },
|
|
26
|
+
...(p.is_sticky
|
|
27
|
+
? [{ category: "Default", value: "unsticky", title: "Clear sticky default",
|
|
28
|
+
description: "hermes profile use --clear" }]
|
|
29
|
+
: [{ category: "Default", value: "sticky", title: "Set as sticky default",
|
|
30
|
+
description: `hermes profile use ${p.name}` }]),
|
|
31
|
+
{ category: "Manage", value: "export", title: "Export",
|
|
32
|
+
description: `hermes profile export ${p.name}` },
|
|
33
|
+
...(p.is_default || p.is_active ? []
|
|
34
|
+
: [{ category: "Manage", value: "delete", title: "Delete",
|
|
35
|
+
description: "irreversible — removes config, env, memory, sessions" }]),
|
|
36
|
+
]
|
|
37
|
+
|
|
38
|
+
dialog.replace(
|
|
39
|
+
<DialogSelect
|
|
40
|
+
title={`Profile · ${p.name}${p.is_active ? " (active)" : ""}`}
|
|
41
|
+
options={opts}
|
|
42
|
+
onSelect={(o) => {
|
|
43
|
+
dialog.clear()
|
|
44
|
+
if (o.value === "soul") return openFile(p.sources.soul.file)
|
|
45
|
+
if (o.value === "config") return openFile(p.sources.config.file)
|
|
46
|
+
if (o.value === "env") return openFile(p.sources.env.file)
|
|
47
|
+
if (o.value === "dir") return openFile(p.path)
|
|
48
|
+
if (o.value === "sticky") return ops.sticky(p)
|
|
49
|
+
if (o.value === "unsticky") return ops.unsticky()
|
|
50
|
+
if (o.value === "export") return ops.export(p)
|
|
51
|
+
if (o.value === "delete") return ops.remove(p)
|
|
52
|
+
}}
|
|
53
|
+
/>,
|
|
54
|
+
)
|
|
55
|
+
}
|