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.
Files changed (175) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +54 -0
  3. package/package.json +82 -0
  4. package/scripts/postinstall.ts +29 -0
  5. package/src/app/gateway.tsx +83 -0
  6. package/src/app/gatewayEvents.ts +203 -0
  7. package/src/app/launch.ts +41 -0
  8. package/src/app/skin.tsx +31 -0
  9. package/src/app/spawnHistory.ts +75 -0
  10. package/src/app/tabs.ts +23 -0
  11. package/src/app/turnReducer.ts +390 -0
  12. package/src/app/useAppKeys.ts +268 -0
  13. package/src/app/useAtRefPopover.ts +99 -0
  14. package/src/app/useInputHistory.ts +66 -0
  15. package/src/app/useSession.ts +102 -0
  16. package/src/app/useSlashCommands.ts +70 -0
  17. package/src/app/useSlashPopover.ts +48 -0
  18. package/src/app.tsx +917 -0
  19. package/src/commands/slash.ts +151 -0
  20. package/src/components/avatar/AnimatedAvatar.tsx +66 -0
  21. package/src/components/avatar/eikon.ts +144 -0
  22. package/src/components/avatar/states/error.ts +1155 -0
  23. package/src/components/avatar/states/idle.ts +1155 -0
  24. package/src/components/avatar/states/index.ts +30 -0
  25. package/src/components/avatar/states/listening.ts +1155 -0
  26. package/src/components/avatar/states/speaking.ts +1155 -0
  27. package/src/components/avatar/states/thinking.ts +1155 -0
  28. package/src/components/avatar/states/working.ts +1155 -0
  29. package/src/components/chat/AtRefPopover.tsx +54 -0
  30. package/src/components/chat/CodeBlock.tsx +67 -0
  31. package/src/components/chat/Composer.tsx +347 -0
  32. package/src/components/chat/DiffBlock.tsx +116 -0
  33. package/src/components/chat/ErrorBlock.tsx +70 -0
  34. package/src/components/chat/MediaChip.tsx +114 -0
  35. package/src/components/chat/MessageItem.tsx +282 -0
  36. package/src/components/chat/MessageList.tsx +114 -0
  37. package/src/components/chat/PromptCard.tsx +359 -0
  38. package/src/components/chat/SlashPopover.tsx +158 -0
  39. package/src/components/chat/ThoughtCloud.tsx +185 -0
  40. package/src/components/chat/TypingIndicator.tsx +25 -0
  41. package/src/components/chat/tool/Subagent.tsx +75 -0
  42. package/src/components/chat/tool/frame.tsx +69 -0
  43. package/src/components/chat/tool/index.tsx +65 -0
  44. package/src/components/chat/tool/preview.ts +57 -0
  45. package/src/components/sidebar/ContextGauge.tsx +102 -0
  46. package/src/components/sidebar/Sidebar.tsx +143 -0
  47. package/src/components/tabs/TabBar.tsx +50 -0
  48. package/src/components/ui/FileLink.tsx +52 -0
  49. package/src/config/index.ts +156 -0
  50. package/src/config/lane.ts +161 -0
  51. package/src/config/models.ts +95 -0
  52. package/src/config/rules.ts +80 -0
  53. package/src/config/schema.ts +308 -0
  54. package/src/dialogs/alert.tsx +52 -0
  55. package/src/dialogs/chafa.tsx +72 -0
  56. package/src/dialogs/confirm.tsx +58 -0
  57. package/src/dialogs/curator.tsx +153 -0
  58. package/src/dialogs/eikon-picker.tsx +95 -0
  59. package/src/dialogs/help.tsx +80 -0
  60. package/src/dialogs/history.tsx +92 -0
  61. package/src/dialogs/info.tsx +115 -0
  62. package/src/dialogs/keys.tsx +170 -0
  63. package/src/dialogs/logs.tsx +42 -0
  64. package/src/dialogs/message.tsx +38 -0
  65. package/src/dialogs/model-picker.tsx +123 -0
  66. package/src/dialogs/new-profile.tsx +69 -0
  67. package/src/dialogs/new-task.tsx +103 -0
  68. package/src/dialogs/profile.tsx +55 -0
  69. package/src/dialogs/rollback.tsx +190 -0
  70. package/src/dialogs/spawn-history.tsx +80 -0
  71. package/src/dialogs/text-prompt.tsx +68 -0
  72. package/src/dialogs/theme-picker.tsx +50 -0
  73. package/src/home/index.ts +23 -0
  74. package/src/home/store.ts +267 -0
  75. package/src/index.tsx +113 -0
  76. package/src/keys/catalog.ts +115 -0
  77. package/src/keys/chord.ts +125 -0
  78. package/src/keys/conflicts.ts +48 -0
  79. package/src/keys/context.tsx +112 -0
  80. package/src/keys/index.ts +5 -0
  81. package/src/keys/list.ts +94 -0
  82. package/src/keys/oc-compat.ts +87 -0
  83. package/src/tabs/Agents.tsx +607 -0
  84. package/src/tabs/Analytics.tsx +154 -0
  85. package/src/tabs/Chat.tsx +50 -0
  86. package/src/tabs/Config.tsx +605 -0
  87. package/src/tabs/Context.tsx +599 -0
  88. package/src/tabs/Cron.tsx +294 -0
  89. package/src/tabs/Env.tsx +227 -0
  90. package/src/tabs/Kanban.tsx +367 -0
  91. package/src/tabs/Memory.tsx +294 -0
  92. package/src/tabs/Sessions.tsx +786 -0
  93. package/src/tabs/Skills.tsx +507 -0
  94. package/src/tabs/Toolsets.tsx +266 -0
  95. package/src/theme/builtin.ts +78 -0
  96. package/src/theme/context.tsx +106 -0
  97. package/src/theme/index.ts +4 -0
  98. package/src/theme/resolve.ts +134 -0
  99. package/src/theme/syntax.ts +31 -0
  100. package/src/theme/themes/aura.json +69 -0
  101. package/src/theme/themes/ayu.json +80 -0
  102. package/src/theme/themes/carbonfox.json +248 -0
  103. package/src/theme/themes/catppuccin-frappe.json +233 -0
  104. package/src/theme/themes/catppuccin-macchiato.json +233 -0
  105. package/src/theme/themes/catppuccin.json +112 -0
  106. package/src/theme/themes/cobalt2.json +228 -0
  107. package/src/theme/themes/cursor.json +249 -0
  108. package/src/theme/themes/dracula.json +219 -0
  109. package/src/theme/themes/everforest.json +241 -0
  110. package/src/theme/themes/flexoki.json +237 -0
  111. package/src/theme/themes/github.json +233 -0
  112. package/src/theme/themes/gruvbox.json +242 -0
  113. package/src/theme/themes/kanagawa.json +77 -0
  114. package/src/theme/themes/lucent-orng.json +237 -0
  115. package/src/theme/themes/material.json +235 -0
  116. package/src/theme/themes/matrix.json +77 -0
  117. package/src/theme/themes/mercury.json +252 -0
  118. package/src/theme/themes/monokai.json +221 -0
  119. package/src/theme/themes/nightowl.json +221 -0
  120. package/src/theme/themes/nord.json +223 -0
  121. package/src/theme/themes/one-dark.json +84 -0
  122. package/src/theme/themes/opencode.json +245 -0
  123. package/src/theme/themes/orng.json +249 -0
  124. package/src/theme/themes/osaka-jade.json +93 -0
  125. package/src/theme/themes/palenight.json +222 -0
  126. package/src/theme/themes/rosepine.json +234 -0
  127. package/src/theme/themes/solarized.json +223 -0
  128. package/src/theme/themes/synthwave84.json +226 -0
  129. package/src/theme/themes/tokyonight.json +243 -0
  130. package/src/theme/themes/vercel.json +245 -0
  131. package/src/theme/themes/vesper.json +218 -0
  132. package/src/theme/themes/zenburn.json +223 -0
  133. package/src/theme/types.ts +119 -0
  134. package/src/types/message.ts +97 -0
  135. package/src/ui/ChafaImage.tsx +64 -0
  136. package/src/ui/Splash.tsx +118 -0
  137. package/src/ui/borders.ts +28 -0
  138. package/src/ui/command.tsx +104 -0
  139. package/src/ui/dialog-select.tsx +164 -0
  140. package/src/ui/dialog.tsx +102 -0
  141. package/src/ui/fmt.ts +82 -0
  142. package/src/ui/kv.tsx +28 -0
  143. package/src/ui/shell.tsx +45 -0
  144. package/src/ui/spinner.tsx +59 -0
  145. package/src/ui/splash-art.ts +123 -0
  146. package/src/ui/table.tsx +117 -0
  147. package/src/ui/ticker.tsx +90 -0
  148. package/src/ui/toast.tsx +130 -0
  149. package/src/utils/categorical.ts +77 -0
  150. package/src/utils/chafa.ts +173 -0
  151. package/src/utils/clipboard.ts +67 -0
  152. package/src/utils/context-segments.ts +317 -0
  153. package/src/utils/control.ts +495 -0
  154. package/src/utils/drop.ts +25 -0
  155. package/src/utils/editor.ts +33 -0
  156. package/src/utils/fuzzy.ts +45 -0
  157. package/src/utils/gateway-client.ts +253 -0
  158. package/src/utils/gateway-types.ts +282 -0
  159. package/src/utils/git.ts +57 -0
  160. package/src/utils/hermes-analytics.ts +134 -0
  161. package/src/utils/hermes-home.ts +821 -0
  162. package/src/utils/hermes-kanban.ts +154 -0
  163. package/src/utils/hermes-profiles.ts +217 -0
  164. package/src/utils/interpolate.ts +31 -0
  165. package/src/utils/math-unicode.ts +818 -0
  166. package/src/utils/memory-activity.ts +140 -0
  167. package/src/utils/open-file.ts +13 -0
  168. package/src/utils/paths.ts +52 -0
  169. package/src/utils/perf.ts +235 -0
  170. package/src/utils/preferences.ts +150 -0
  171. package/src/utils/sessions-db.ts +396 -0
  172. package/src/utils/subagent-tree.ts +146 -0
  173. package/src/utils/terminal-reset.ts +129 -0
  174. package/src/utils/tips.ts +67 -0
  175. 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
+ }