herm-tui 1.0.0-dev.1 → 1.0.0-dev.11
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/README.md +8 -4
- package/assets/eikons/ares.eikon +367 -0
- package/assets/eikons/default.eikon +398 -0
- package/assets/eikons/mono.eikon +395 -0
- package/db.worker.js +81 -0
- package/highlights-eq9cgrbb.scm +604 -0
- package/highlights-ghv9g403.scm +205 -0
- package/highlights-hk7bwhj4.scm +284 -0
- package/highlights-r812a2qc.scm +150 -0
- package/highlights-x6tmsnaa.scm +115 -0
- package/index.js +4151 -0
- package/injections-73j83es3.scm +27 -0
- package/package.json +14 -64
- package/parser.worker.js +8 -0
- package/tree-sitter-3jzf13jk.wasm +0 -0
- package/tree-sitter-javascript-nd0q4pe9.wasm +0 -0
- package/tree-sitter-markdown-411r6y9b.wasm +0 -0
- package/tree-sitter-markdown_inline-j5349f42.wasm +0 -0
- package/tree-sitter-typescript-zxjzwt75.wasm +0 -0
- package/tree-sitter-zig-e78zbjpm.wasm +0 -0
- package/scripts/postinstall.ts +0 -29
- package/src/app/gateway.tsx +0 -83
- package/src/app/gatewayEvents.ts +0 -203
- package/src/app/launch.ts +0 -41
- package/src/app/skin.tsx +0 -31
- package/src/app/spawnHistory.ts +0 -75
- package/src/app/tabs.ts +0 -23
- package/src/app/turnReducer.ts +0 -390
- package/src/app/useAppKeys.ts +0 -268
- package/src/app/useAtRefPopover.ts +0 -99
- package/src/app/useInputHistory.ts +0 -66
- package/src/app/useSession.ts +0 -102
- package/src/app/useSlashCommands.ts +0 -70
- package/src/app/useSlashPopover.ts +0 -48
- package/src/app.tsx +0 -917
- package/src/commands/slash.ts +0 -151
- package/src/components/avatar/AnimatedAvatar.tsx +0 -66
- package/src/components/avatar/eikon.ts +0 -144
- package/src/components/avatar/states/error.ts +0 -1155
- package/src/components/avatar/states/idle.ts +0 -1155
- package/src/components/avatar/states/index.ts +0 -30
- package/src/components/avatar/states/listening.ts +0 -1155
- package/src/components/avatar/states/speaking.ts +0 -1155
- package/src/components/avatar/states/thinking.ts +0 -1155
- package/src/components/avatar/states/working.ts +0 -1155
- package/src/components/chat/AtRefPopover.tsx +0 -54
- package/src/components/chat/CodeBlock.tsx +0 -67
- package/src/components/chat/Composer.tsx +0 -347
- package/src/components/chat/DiffBlock.tsx +0 -116
- package/src/components/chat/ErrorBlock.tsx +0 -70
- package/src/components/chat/MediaChip.tsx +0 -114
- package/src/components/chat/MessageItem.tsx +0 -282
- package/src/components/chat/MessageList.tsx +0 -114
- package/src/components/chat/PromptCard.tsx +0 -359
- package/src/components/chat/SlashPopover.tsx +0 -158
- package/src/components/chat/ThoughtCloud.tsx +0 -185
- package/src/components/chat/TypingIndicator.tsx +0 -25
- package/src/components/chat/tool/Subagent.tsx +0 -75
- package/src/components/chat/tool/frame.tsx +0 -69
- package/src/components/chat/tool/index.tsx +0 -65
- package/src/components/chat/tool/preview.ts +0 -57
- package/src/components/sidebar/ContextGauge.tsx +0 -102
- package/src/components/sidebar/Sidebar.tsx +0 -143
- package/src/components/tabs/TabBar.tsx +0 -50
- package/src/components/ui/FileLink.tsx +0 -52
- package/src/config/index.ts +0 -156
- package/src/config/lane.ts +0 -161
- package/src/config/models.ts +0 -95
- package/src/config/rules.ts +0 -80
- package/src/config/schema.ts +0 -308
- package/src/dialogs/alert.tsx +0 -52
- package/src/dialogs/chafa.tsx +0 -72
- package/src/dialogs/confirm.tsx +0 -58
- package/src/dialogs/curator.tsx +0 -153
- package/src/dialogs/eikon-picker.tsx +0 -95
- package/src/dialogs/help.tsx +0 -80
- package/src/dialogs/history.tsx +0 -92
- package/src/dialogs/info.tsx +0 -115
- package/src/dialogs/keys.tsx +0 -170
- package/src/dialogs/logs.tsx +0 -42
- package/src/dialogs/message.tsx +0 -38
- package/src/dialogs/model-picker.tsx +0 -123
- package/src/dialogs/new-profile.tsx +0 -69
- package/src/dialogs/new-task.tsx +0 -103
- package/src/dialogs/profile.tsx +0 -55
- package/src/dialogs/rollback.tsx +0 -190
- package/src/dialogs/spawn-history.tsx +0 -80
- package/src/dialogs/text-prompt.tsx +0 -68
- package/src/dialogs/theme-picker.tsx +0 -50
- package/src/home/index.ts +0 -23
- package/src/home/store.ts +0 -267
- package/src/index.tsx +0 -113
- package/src/keys/catalog.ts +0 -115
- package/src/keys/chord.ts +0 -125
- package/src/keys/conflicts.ts +0 -48
- package/src/keys/context.tsx +0 -112
- package/src/keys/index.ts +0 -5
- package/src/keys/list.ts +0 -94
- package/src/keys/oc-compat.ts +0 -87
- package/src/tabs/Agents.tsx +0 -607
- package/src/tabs/Analytics.tsx +0 -154
- package/src/tabs/Chat.tsx +0 -50
- package/src/tabs/Config.tsx +0 -605
- package/src/tabs/Context.tsx +0 -599
- package/src/tabs/Cron.tsx +0 -294
- package/src/tabs/Env.tsx +0 -227
- package/src/tabs/Kanban.tsx +0 -367
- package/src/tabs/Memory.tsx +0 -294
- package/src/tabs/Sessions.tsx +0 -786
- package/src/tabs/Skills.tsx +0 -507
- package/src/tabs/Toolsets.tsx +0 -266
- package/src/theme/builtin.ts +0 -78
- package/src/theme/context.tsx +0 -106
- package/src/theme/index.ts +0 -4
- package/src/theme/resolve.ts +0 -134
- package/src/theme/syntax.ts +0 -31
- package/src/theme/themes/aura.json +0 -69
- package/src/theme/themes/ayu.json +0 -80
- package/src/theme/themes/carbonfox.json +0 -248
- package/src/theme/themes/catppuccin-frappe.json +0 -233
- package/src/theme/themes/catppuccin-macchiato.json +0 -233
- package/src/theme/themes/catppuccin.json +0 -112
- package/src/theme/themes/cobalt2.json +0 -228
- package/src/theme/themes/cursor.json +0 -249
- package/src/theme/themes/dracula.json +0 -219
- package/src/theme/themes/everforest.json +0 -241
- package/src/theme/themes/flexoki.json +0 -237
- package/src/theme/themes/github.json +0 -233
- package/src/theme/themes/gruvbox.json +0 -242
- package/src/theme/themes/kanagawa.json +0 -77
- package/src/theme/themes/lucent-orng.json +0 -237
- package/src/theme/themes/material.json +0 -235
- package/src/theme/themes/matrix.json +0 -77
- package/src/theme/themes/mercury.json +0 -252
- package/src/theme/themes/monokai.json +0 -221
- package/src/theme/themes/nightowl.json +0 -221
- package/src/theme/themes/nord.json +0 -223
- package/src/theme/themes/one-dark.json +0 -84
- package/src/theme/themes/opencode.json +0 -245
- package/src/theme/themes/orng.json +0 -249
- package/src/theme/themes/osaka-jade.json +0 -93
- package/src/theme/themes/palenight.json +0 -222
- package/src/theme/themes/rosepine.json +0 -234
- package/src/theme/themes/solarized.json +0 -223
- package/src/theme/themes/synthwave84.json +0 -226
- package/src/theme/themes/tokyonight.json +0 -243
- package/src/theme/themes/vercel.json +0 -245
- package/src/theme/themes/vesper.json +0 -218
- package/src/theme/themes/zenburn.json +0 -223
- package/src/theme/types.ts +0 -119
- package/src/types/message.ts +0 -97
- package/src/ui/ChafaImage.tsx +0 -64
- package/src/ui/Splash.tsx +0 -118
- package/src/ui/borders.ts +0 -28
- package/src/ui/command.tsx +0 -104
- package/src/ui/dialog-select.tsx +0 -164
- package/src/ui/dialog.tsx +0 -102
- package/src/ui/fmt.ts +0 -82
- package/src/ui/kv.tsx +0 -28
- package/src/ui/shell.tsx +0 -45
- package/src/ui/spinner.tsx +0 -59
- package/src/ui/splash-art.ts +0 -123
- package/src/ui/table.tsx +0 -117
- package/src/ui/ticker.tsx +0 -90
- package/src/ui/toast.tsx +0 -130
- package/src/utils/categorical.ts +0 -77
- package/src/utils/chafa.ts +0 -173
- package/src/utils/clipboard.ts +0 -67
- package/src/utils/context-segments.ts +0 -317
- package/src/utils/control.ts +0 -495
- package/src/utils/drop.ts +0 -25
- package/src/utils/editor.ts +0 -33
- package/src/utils/fuzzy.ts +0 -45
- package/src/utils/gateway-client.ts +0 -253
- package/src/utils/gateway-types.ts +0 -282
- package/src/utils/git.ts +0 -57
- package/src/utils/hermes-analytics.ts +0 -134
- package/src/utils/hermes-home.ts +0 -821
- package/src/utils/hermes-kanban.ts +0 -154
- package/src/utils/hermes-profiles.ts +0 -217
- package/src/utils/interpolate.ts +0 -31
- package/src/utils/math-unicode.ts +0 -818
- package/src/utils/memory-activity.ts +0 -140
- package/src/utils/open-file.ts +0 -13
- package/src/utils/paths.ts +0 -52
- package/src/utils/perf.ts +0 -235
- package/src/utils/preferences.ts +0 -150
- package/src/utils/sessions-db.ts +0 -396
- package/src/utils/subagent-tree.ts +0 -146
- package/src/utils/terminal-reset.ts +0 -129
- package/src/utils/tips.ts +0 -67
- package/src/utils/tokens.ts +0 -87
|
@@ -1,54 +0,0 @@
|
|
|
1
|
-
// @-ref popover — same sliding-window shell as SlashPopover but with
|
|
2
|
-
// a flat file/keyword list (no categories, no source badges).
|
|
3
|
-
|
|
4
|
-
import { memo } from "react"
|
|
5
|
-
import { useTheme } from "../../theme"
|
|
6
|
-
import type { AtRefItem } from "../../app/useAtRefPopover"
|
|
7
|
-
|
|
8
|
-
type Props = {
|
|
9
|
-
readonly items: ReadonlyArray<AtRefItem>
|
|
10
|
-
readonly cursor: number
|
|
11
|
-
readonly onCursor: (idx: number) => void
|
|
12
|
-
readonly onSelect: (idx: number) => void
|
|
13
|
-
}
|
|
14
|
-
|
|
15
|
-
const MAX_VISIBLE = 10
|
|
16
|
-
|
|
17
|
-
export const AtRefPopover = memo(({ items, cursor, onCursor, onSelect }: Props) => {
|
|
18
|
-
const theme = useTheme().theme
|
|
19
|
-
|
|
20
|
-
const start = Math.max(0, Math.min(cursor - 2, items.length - MAX_VISIBLE))
|
|
21
|
-
const visible = items.slice(start, start + MAX_VISIBLE)
|
|
22
|
-
const above = start > 0
|
|
23
|
-
const below = start + MAX_VISIBLE < items.length
|
|
24
|
-
const height = visible.length + 2 + (above ? 1 : 0) + (below ? 1 : 0)
|
|
25
|
-
|
|
26
|
-
return (
|
|
27
|
-
<box flexDirection="column" border borderStyle="single"
|
|
28
|
-
borderColor={theme.border} backgroundColor={theme.backgroundPanel}
|
|
29
|
-
paddingX={1} height={height}>
|
|
30
|
-
{above ? <box height={1} paddingLeft={1}><text fg={theme.textMuted}>↑ more</text></box> : null}
|
|
31
|
-
{visible.map((it, j) => {
|
|
32
|
-
const i = start + j
|
|
33
|
-
const active = i === cursor
|
|
34
|
-
return (
|
|
35
|
-
<box key={it.text} height={1} flexDirection="row"
|
|
36
|
-
backgroundColor={active ? theme.backgroundElement : undefined}
|
|
37
|
-
onMouseOver={() => onCursor(i)} onMouseDown={() => onSelect(i)}
|
|
38
|
-
paddingLeft={2} paddingRight={1}>
|
|
39
|
-
<box flexGrow={1} height={1} overflow="hidden">
|
|
40
|
-
<text>
|
|
41
|
-
<span fg={active ? theme.primary : theme.text}>{it.display}</span>
|
|
42
|
-
{it.text !== it.display
|
|
43
|
-
? <span fg={theme.textMuted}>{` ${it.text}`}</span>
|
|
44
|
-
: null}
|
|
45
|
-
</text>
|
|
46
|
-
</box>
|
|
47
|
-
<box height={1}><text fg={theme.textMuted}>{it.meta}</text></box>
|
|
48
|
-
</box>
|
|
49
|
-
)
|
|
50
|
-
})}
|
|
51
|
-
{below ? <box height={1} paddingLeft={1}><text fg={theme.textMuted}>↓ more</text></box> : null}
|
|
52
|
-
</box>
|
|
53
|
-
)
|
|
54
|
-
})
|
|
@@ -1,67 +0,0 @@
|
|
|
1
|
-
// Fenced code block chrome — bg panel, ┃-bar, lang label, click-to-copy.
|
|
2
|
-
// The body is a <code> renderable so tree-sitter highlighting stays
|
|
3
|
-
// identical to what MarkdownRenderable would have produced; the point
|
|
4
|
-
// of pulling fences out of the markdown stream is purely to wrap them.
|
|
5
|
-
|
|
6
|
-
import { memo, useState } from "react"
|
|
7
|
-
import { LEFT_BAR } from "../../ui/borders"
|
|
8
|
-
import { useTheme } from "../../theme"
|
|
9
|
-
import { useToast } from "../../ui/toast"
|
|
10
|
-
import { copy } from "../../utils/clipboard"
|
|
11
|
-
|
|
12
|
-
// Info-string → tree-sitter filetype. Only the handful that differ
|
|
13
|
-
// from their canonical fence tag; everything else passes through.
|
|
14
|
-
const FILETYPE: Record<string, string> = {
|
|
15
|
-
ts: "typescript", tsx: "typescript", js: "javascript", jsx: "javascript",
|
|
16
|
-
py: "python", rb: "ruby", rs: "rust", sh: "bash", shell: "bash",
|
|
17
|
-
yml: "yaml", md: "markdown",
|
|
18
|
-
}
|
|
19
|
-
|
|
20
|
-
export const CodeBlock = memo((props: { code: string; lang?: string; streaming?: boolean }) => {
|
|
21
|
-
const { theme, syntaxStyle } = useTheme()
|
|
22
|
-
const toast = useToast()
|
|
23
|
-
const [hover, setHover] = useState(false)
|
|
24
|
-
|
|
25
|
-
const ft = props.lang ? FILETYPE[props.lang.toLowerCase()] ?? props.lang.toLowerCase() : undefined
|
|
26
|
-
const lines = props.code.split("\n").length
|
|
27
|
-
|
|
28
|
-
const onCopy = () => {
|
|
29
|
-
void copy(props.code)
|
|
30
|
-
toast.show({ variant: "success", message: `Copied ${lines} line${lines === 1 ? "" : "s"}` })
|
|
31
|
-
}
|
|
32
|
-
|
|
33
|
-
return (
|
|
34
|
-
<box
|
|
35
|
-
flexDirection="column"
|
|
36
|
-
marginTop={1}
|
|
37
|
-
border={["left"]}
|
|
38
|
-
borderColor={theme.border}
|
|
39
|
-
customBorderChars={LEFT_BAR}
|
|
40
|
-
backgroundColor={theme.backgroundPanel}
|
|
41
|
-
paddingLeft={1}
|
|
42
|
-
>
|
|
43
|
-
<box
|
|
44
|
-
flexDirection="row" height={1}
|
|
45
|
-
backgroundColor={theme.backgroundElement}
|
|
46
|
-
onMouseDown={onCopy}
|
|
47
|
-
onMouseOver={() => setHover(true)}
|
|
48
|
-
onMouseOut={() => setHover(false)}
|
|
49
|
-
>
|
|
50
|
-
<box flexGrow={1} paddingLeft={1}>
|
|
51
|
-
<text fg={theme.textMuted}>{props.lang || "text"}</text>
|
|
52
|
-
</box>
|
|
53
|
-
<box paddingRight={1}>
|
|
54
|
-
<text fg={hover ? theme.accent : theme.textMuted}>
|
|
55
|
-
{hover ? "⧉ copy" : `${lines} ln`}
|
|
56
|
-
</text>
|
|
57
|
-
</box>
|
|
58
|
-
</box>
|
|
59
|
-
<box paddingX={1} paddingY={ft ? 0 : 1}>
|
|
60
|
-
{ft
|
|
61
|
-
? <code content={props.code} filetype={ft} syntaxStyle={syntaxStyle}
|
|
62
|
-
fg={theme.text} wrapMode="none" streaming={props.streaming} />
|
|
63
|
-
: <text fg={theme.text}>{props.code}</text>}
|
|
64
|
-
</box>
|
|
65
|
-
</box>
|
|
66
|
-
)
|
|
67
|
-
})
|
|
@@ -1,347 +0,0 @@
|
|
|
1
|
-
// Composer — owns the chat input buffer, slash popover, ghost completion
|
|
2
|
-
// and prompt history. The shell (app.tsx) drives keyboard routing through
|
|
3
|
-
// the imperative handle so there is exactly one global useKeyboard.
|
|
4
|
-
|
|
5
|
-
import { forwardRef, memo, useImperativeHandle, useRef, useState, useCallback, useMemo, useEffect } from "react"
|
|
6
|
-
import type { TextareaRenderable, PasteEvent } from "@opentui/core"
|
|
7
|
-
import { decodePasteBytes } from "@opentui/core"
|
|
8
|
-
import { useTheme } from "../../theme"
|
|
9
|
-
import { useKeys, toBindings } from "../../keys"
|
|
10
|
-
import { useGateway } from "../../app/gateway"
|
|
11
|
-
import type { ImageAttachResponse, DropDetectResponse } from "../../utils/gateway-types"
|
|
12
|
-
import { looksLikePath } from "../../utils/drop"
|
|
13
|
-
import type { SlashCommand } from "../../commands/slash"
|
|
14
|
-
import { useSlashPopover } from "../../app/useSlashPopover"
|
|
15
|
-
import { useAtRefPopover } from "../../app/useAtRefPopover"
|
|
16
|
-
import { useInputHistory } from "../../app/useInputHistory"
|
|
17
|
-
import { SlashPopover } from "./SlashPopover"
|
|
18
|
-
import { AtRefPopover } from "./AtRefPopover"
|
|
19
|
-
import { ChafaImage } from "../../ui/ChafaImage"
|
|
20
|
-
import { trunc } from "../../ui/fmt"
|
|
21
|
-
|
|
22
|
-
export type ComposerHandle = {
|
|
23
|
-
value: () => string
|
|
24
|
-
set: (v: string) => void
|
|
25
|
-
/** Insert text at the cursor (verbatim, multi-line ok). */
|
|
26
|
-
insert: (text: string) => void
|
|
27
|
-
/** Logical line count of the current buffer. */
|
|
28
|
-
lines: () => number
|
|
29
|
-
/** True iff the buffer is empty (no text, no whitespace-only). */
|
|
30
|
-
isEmpty: () => boolean
|
|
31
|
-
popOpen: () => boolean
|
|
32
|
-
popNav: (d: -1 | 1) => void
|
|
33
|
-
popAccept: () => void
|
|
34
|
-
popCancel: () => void
|
|
35
|
-
/** Returns false when not applicable (multi-line buffer → caller lets textarea own ↑/↓). */
|
|
36
|
-
historyUp: () => boolean
|
|
37
|
-
historyDown: () => boolean
|
|
38
|
-
}
|
|
39
|
-
|
|
40
|
-
type Props = {
|
|
41
|
-
focused: boolean
|
|
42
|
-
ready: boolean
|
|
43
|
-
streaming: boolean
|
|
44
|
-
status?: string
|
|
45
|
-
queue?: ReadonlyArray<string>
|
|
46
|
-
attachments?: ReadonlyArray<ImageAttachResponse>
|
|
47
|
-
cmds: ReadonlyArray<SlashCommand>
|
|
48
|
-
onSend: (text: string) => void
|
|
49
|
-
onSlash: (cmd: SlashCommand) => void
|
|
50
|
-
onAttach?: (r: ImageAttachResponse) => void
|
|
51
|
-
onEnqueue?: (text: string) => void
|
|
52
|
-
onDequeue?: (i: number) => void
|
|
53
|
-
/** Enter pressed with an empty buffer. Return true to consume. */
|
|
54
|
-
onEmptyEnter?: () => boolean
|
|
55
|
-
/** Fires on the empty↔non-empty edge of the input buffer. */
|
|
56
|
-
onDirty?: (dirty: boolean) => void
|
|
57
|
-
}
|
|
58
|
-
|
|
59
|
-
const MAX_ROWS = 6
|
|
60
|
-
|
|
61
|
-
function fmt(n: number): string {
|
|
62
|
-
if (n < 1000) return String(n)
|
|
63
|
-
if (n < 1_000_000) return `${(n / 1000).toFixed(1)}k`
|
|
64
|
-
return `${(n / 1_000_000).toFixed(2)}M`
|
|
65
|
-
}
|
|
66
|
-
|
|
67
|
-
export const Composer = memo(forwardRef<ComposerHandle, Props>((props, ref) => {
|
|
68
|
-
const theme = useTheme().theme
|
|
69
|
-
const gw = useGateway()
|
|
70
|
-
const keys = useKeys()
|
|
71
|
-
const ta = useRef<TextareaRenderable | null>(null)
|
|
72
|
-
// Mirror of the textarea buffer. The renderable is the source of truth;
|
|
73
|
-
// this drives React-side derivations (popover matching, row count, hints).
|
|
74
|
-
const [input, setInput] = useState("")
|
|
75
|
-
|
|
76
|
-
// Slash and @-ref popovers key off the first line only — both grammars
|
|
77
|
-
// are single-line prefixes, and a newline is a hard boundary.
|
|
78
|
-
const head = useMemo(() => {
|
|
79
|
-
const i = input.indexOf("\n")
|
|
80
|
-
return i < 0 ? input : input.slice(0, i)
|
|
81
|
-
}, [input])
|
|
82
|
-
|
|
83
|
-
const pop = useSlashPopover(head, props.cmds)
|
|
84
|
-
const at = useAtRefPopover(head)
|
|
85
|
-
|
|
86
|
-
const write = useCallback((v: string) => {
|
|
87
|
-
ta.current?.setText(v)
|
|
88
|
-
ta.current?.gotoBufferEnd()
|
|
89
|
-
setInput(v)
|
|
90
|
-
}, [])
|
|
91
|
-
|
|
92
|
-
const hist = useInputHistory(input, write)
|
|
93
|
-
|
|
94
|
-
// Merged over the renderable's default map (which has bare return →
|
|
95
|
-
// newline), so input.submit's `return` entry overrides it and the
|
|
96
|
-
// newline alternates add on top. Recomputes only when a user rebinds.
|
|
97
|
-
const bindings = useMemo(() => [
|
|
98
|
-
...toBindings(keys.chord("input.submit"), "submit"),
|
|
99
|
-
...toBindings(keys.chord("input.newline"), "newline"),
|
|
100
|
-
], [keys])
|
|
101
|
-
|
|
102
|
-
// Hold latest pop/props in a ref so the imperative handle is stable.
|
|
103
|
-
const live = useRef({ pop, at, props, input })
|
|
104
|
-
live.current = { pop, at, props, input }
|
|
105
|
-
|
|
106
|
-
// Notify parent only on the empty↔non-empty edge so the splash
|
|
107
|
-
// continue-prompt can hide the moment typing starts.
|
|
108
|
-
const wasDirty = useRef(false)
|
|
109
|
-
useEffect(() => {
|
|
110
|
-
const dirty = input.trim().length > 0
|
|
111
|
-
if (dirty === wasDirty.current) return
|
|
112
|
-
wasDirty.current = dirty
|
|
113
|
-
live.current.props.onDirty?.(dirty)
|
|
114
|
-
}, [input])
|
|
115
|
-
|
|
116
|
-
// Selecting a popover entry: subcommand synthetics (name contains a
|
|
117
|
-
// space) complete the input for further typing; real commands dispatch.
|
|
118
|
-
const select = (c: SlashCommand) => {
|
|
119
|
-
if (c.name.includes(" ")) { write(`/${c.name} `); return }
|
|
120
|
-
write("")
|
|
121
|
-
live.current.props.onSlash(c)
|
|
122
|
-
}
|
|
123
|
-
|
|
124
|
-
const atAccept = (idx?: number) => {
|
|
125
|
-
const next = live.current.at.accept(live.current.input, idx)
|
|
126
|
-
if (next !== null) write(next)
|
|
127
|
-
}
|
|
128
|
-
|
|
129
|
-
// Paste routing, in priority order:
|
|
130
|
-
// 1. Single-line paste that *looks* like a local path → ask the gateway.
|
|
131
|
-
// input.detect_drop is authoritative (stats the file, handles file://,
|
|
132
|
-
// quoting, escaped spaces, ~/ expansion, WSL drive rewriting). Image
|
|
133
|
-
// hits append to session["attached_images"] server-side; herm mirrors
|
|
134
|
-
// the chip and inserts only the trailing remainder text, not the
|
|
135
|
-
// `[User attached image: …]` placeholder (that's for blind clients).
|
|
136
|
-
// Non-image hits (pdf/txt/…) insert the `[User attached file: …]`
|
|
137
|
-
// wrapper so the agent sees the path. Any miss falls through.
|
|
138
|
-
// 2. ≥5 lines → gateway writes a temp file and hands back a
|
|
139
|
-
// `[Pasted #N …]` placeholder (hermes CLI convention; expanded
|
|
140
|
-
// server-side in prompt.submit).
|
|
141
|
-
// 3. Otherwise insert verbatim minus trailing newlines — terminals append
|
|
142
|
-
// one on bracketed paste and `echo`/`cat` output copied from a shell
|
|
143
|
-
// always carries one, so a naive 1-line paste would otherwise push the
|
|
144
|
-
// cursor to a blank second row. A paste that is *only* newlines is let
|
|
145
|
-
// through unchanged (intentional line break).
|
|
146
|
-
const paste = useCallback((e: PasteEvent) => {
|
|
147
|
-
e.preventDefault()
|
|
148
|
-
const raw = decodePasteBytes(e.bytes).replace(/\r\n?/g, "\n")
|
|
149
|
-
const text = /[^\n]/.test(raw) ? raw.replace(/\n+$/, "") : raw
|
|
150
|
-
const verbatim = () => ta.current?.insertText(text)
|
|
151
|
-
if (looksLikePath(text)) {
|
|
152
|
-
gw.request<DropDetectResponse>("input.detect_drop", { text })
|
|
153
|
-
.then(r => {
|
|
154
|
-
if (!r.matched) return verbatim()
|
|
155
|
-
if (r.is_image) {
|
|
156
|
-
const { path, count, name, width, height, token_estimate } = r
|
|
157
|
-
live.current.props.onAttach?.({ attached: true, path, count, name, width, height, token_estimate })
|
|
158
|
-
if (!r.text.startsWith("[User attached")) ta.current?.insertText(r.text + " ")
|
|
159
|
-
return
|
|
160
|
-
}
|
|
161
|
-
ta.current?.insertText(r.text + " ")
|
|
162
|
-
})
|
|
163
|
-
.catch(verbatim)
|
|
164
|
-
return
|
|
165
|
-
}
|
|
166
|
-
if (text.split("\n").length < 5) return verbatim()
|
|
167
|
-
gw.request<{ placeholder: string }>("paste.collapse", { text })
|
|
168
|
-
.then(r => ta.current?.insertText(r.placeholder + " "))
|
|
169
|
-
.catch(verbatim)
|
|
170
|
-
}, [gw])
|
|
171
|
-
|
|
172
|
-
const submit = () => {
|
|
173
|
-
// While streaming, slash/at popovers are suppressed; anything
|
|
174
|
-
// typed is a plain prompt to enqueue.
|
|
175
|
-
if (live.current.props.streaming) {
|
|
176
|
-
const text = live.current.input.trim()
|
|
177
|
-
if (!text || !live.current.props.ready) return
|
|
178
|
-
hist.push(text)
|
|
179
|
-
write("")
|
|
180
|
-
live.current.props.onEnqueue?.(text)
|
|
181
|
-
return
|
|
182
|
-
}
|
|
183
|
-
const a = live.current.at
|
|
184
|
-
if (a.open) return atAccept()
|
|
185
|
-
const p = live.current.pop
|
|
186
|
-
if (p.open) {
|
|
187
|
-
const c = p.popover?.[p.cursor]
|
|
188
|
-
if (c) select(c)
|
|
189
|
-
return
|
|
190
|
-
}
|
|
191
|
-
const text = live.current.input.trim()
|
|
192
|
-
const hasAtt = (live.current.props.attachments?.length ?? 0) > 0
|
|
193
|
-
if (!text && !hasAtt) { live.current.props.onEmptyEnter?.(); return }
|
|
194
|
-
if (!live.current.props.ready) return
|
|
195
|
-
if (text) hist.push(text)
|
|
196
|
-
write("")
|
|
197
|
-
live.current.props.onSend(text)
|
|
198
|
-
}
|
|
199
|
-
|
|
200
|
-
const multi = () => live.current.input.includes("\n")
|
|
201
|
-
|
|
202
|
-
useImperativeHandle(ref, () => ({
|
|
203
|
-
value: () => live.current.input,
|
|
204
|
-
set: write,
|
|
205
|
-
insert: (text) => ta.current?.insertText(text),
|
|
206
|
-
lines: () => (ta.current?.lineCount ?? 1),
|
|
207
|
-
isEmpty: () => live.current.input.trim().length === 0,
|
|
208
|
-
popOpen: () => live.current.pop.open || live.current.at.open,
|
|
209
|
-
popNav: (d) => {
|
|
210
|
-
const a = live.current.at
|
|
211
|
-
if (a.open) return a.setCursor(c => Math.max(0, Math.min(a.items.length - 1, c + d)))
|
|
212
|
-
const max = (live.current.pop.popover?.length ?? 1) - 1
|
|
213
|
-
pop.setCursor(c => Math.max(0, Math.min(max, c + d)))
|
|
214
|
-
},
|
|
215
|
-
popAccept: () => {
|
|
216
|
-
const a = live.current.at
|
|
217
|
-
if (a.open) return atAccept()
|
|
218
|
-
const p = live.current.pop
|
|
219
|
-
const c = p.popover?.[p.cursor]
|
|
220
|
-
if (c) write(`/${c.name}${c.name.includes(" ") ? " " : ""}`)
|
|
221
|
-
},
|
|
222
|
-
popCancel: () => {
|
|
223
|
-
const a = live.current.at
|
|
224
|
-
if (a.open) return a.dismiss()
|
|
225
|
-
write("")
|
|
226
|
-
},
|
|
227
|
-
historyUp: () => { if (multi()) return false; hist.up(); return true },
|
|
228
|
-
historyDown: () => { if (multi()) return false; hist.down(); return true },
|
|
229
|
-
}), [hist.up, hist.down, pop.setCursor, write])
|
|
230
|
-
|
|
231
|
-
const active = props.focused && !props.streaming
|
|
232
|
-
const label = !props.ready ? "Connecting..."
|
|
233
|
-
: props.streaming ? (props.status || "Generating...")
|
|
234
|
-
: "Ready"
|
|
235
|
-
const dot = props.ready ? (props.streaming ? theme.warning : theme.success) : theme.error
|
|
236
|
-
|
|
237
|
-
// Logical-line row count (wrap-induced growth ignored; yoga sizes the
|
|
238
|
-
// textarea, this only positions the absolute popover above the border).
|
|
239
|
-
const rows = Math.min(MAX_ROWS, Math.max(1, input.split("\n").length))
|
|
240
|
-
const lift = rows + 3
|
|
241
|
-
|
|
242
|
-
return (
|
|
243
|
-
<box flexDirection="column" position="relative">
|
|
244
|
-
{active && pop.open ? (
|
|
245
|
-
<box position="absolute" bottom={lift} left={0} right={0}>
|
|
246
|
-
<SlashPopover
|
|
247
|
-
commands={pop.popover!}
|
|
248
|
-
cursor={pop.cursor}
|
|
249
|
-
onCursor={pop.setCursor}
|
|
250
|
-
onSelect={select}
|
|
251
|
-
/>
|
|
252
|
-
</box>
|
|
253
|
-
) : active && at.open ? (
|
|
254
|
-
<box position="absolute" bottom={lift} left={0} right={0}>
|
|
255
|
-
<AtRefPopover
|
|
256
|
-
items={at.items}
|
|
257
|
-
cursor={at.cursor}
|
|
258
|
-
onCursor={at.setCursor}
|
|
259
|
-
onSelect={atAccept}
|
|
260
|
-
/>
|
|
261
|
-
</box>
|
|
262
|
-
) : null}
|
|
263
|
-
|
|
264
|
-
{(props.queue?.length ?? 0) > 0 ? (
|
|
265
|
-
<box flexDirection="column" paddingX={1} paddingBottom={1}>
|
|
266
|
-
{props.queue!.map((q, i) => (
|
|
267
|
-
<box key={i} height={1} onMouseDown={() => props.onDequeue?.(i)}>
|
|
268
|
-
<text>
|
|
269
|
-
<span fg={theme.borderSubtle}>{i === 0 ? "╭" : "│"} </span>
|
|
270
|
-
<span fg={theme.textMuted}>⏸ {i + 1}. {trunc(q, 60)}</span>
|
|
271
|
-
</text>
|
|
272
|
-
</box>
|
|
273
|
-
))}
|
|
274
|
-
</box>
|
|
275
|
-
) : null}
|
|
276
|
-
|
|
277
|
-
{(props.attachments?.length ?? 0) > 0 ? (
|
|
278
|
-
<box flexDirection="column" paddingX={1} paddingBottom={1} gap={1}>
|
|
279
|
-
{props.attachments!.map(a => a.path
|
|
280
|
-
? <ChafaImage key={`p-${a.path}`} path={a.path} width={60} />
|
|
281
|
-
: null)}
|
|
282
|
-
</box>
|
|
283
|
-
) : null}
|
|
284
|
-
|
|
285
|
-
{(props.attachments?.length ?? 0) > 0 ? (
|
|
286
|
-
<box flexDirection="row" flexWrap="wrap" gap={1} paddingX={1} paddingBottom={1}>
|
|
287
|
-
{props.attachments!.map((a, i) => (
|
|
288
|
-
<text key={a.path ?? i}>
|
|
289
|
-
<span bg={theme.accent} fg={theme.background}> img </span>
|
|
290
|
-
<span bg={theme.backgroundElement} fg={theme.textMuted}> {a.name ?? `image ${i + 1}`} </span>
|
|
291
|
-
{a.width && a.height
|
|
292
|
-
? <span bg={theme.backgroundElement} fg={theme.textMuted}>{a.width}×{a.height} </span>
|
|
293
|
-
: null}
|
|
294
|
-
{a.token_estimate
|
|
295
|
-
? <span bg={theme.backgroundElement} fg={theme.textMuted}>~{fmt(a.token_estimate)}t </span>
|
|
296
|
-
: null}
|
|
297
|
-
<span fg={theme.textMuted}> </span>
|
|
298
|
-
<span fg={theme.textMuted}>⌫ to detach</span>
|
|
299
|
-
</text>
|
|
300
|
-
))}
|
|
301
|
-
</box>
|
|
302
|
-
) : null}
|
|
303
|
-
|
|
304
|
-
<box
|
|
305
|
-
border
|
|
306
|
-
borderStyle="single"
|
|
307
|
-
borderColor={active ? theme.borderActive : theme.border}
|
|
308
|
-
flexDirection="row"
|
|
309
|
-
position="relative"
|
|
310
|
-
>
|
|
311
|
-
<box width={1}><text fg={theme.primary}>{">"}</text></box>
|
|
312
|
-
<box width={1} />
|
|
313
|
-
<textarea
|
|
314
|
-
ref={ta}
|
|
315
|
-
onContentChange={() => setInput(ta.current?.plainText ?? "")}
|
|
316
|
-
onSubmit={submit}
|
|
317
|
-
onPaste={paste}
|
|
318
|
-
keyBindings={bindings}
|
|
319
|
-
wrapMode="word"
|
|
320
|
-
minHeight={1}
|
|
321
|
-
maxHeight={MAX_ROWS}
|
|
322
|
-
placeholder={props.streaming ? "Type to queue... (Enter queues, click chip to edit)" : "Message Hermes... (/ for commands, Shift+Enter for newline)"}
|
|
323
|
-
focused={props.focused}
|
|
324
|
-
textColor={theme.text}
|
|
325
|
-
focusedTextColor={theme.text}
|
|
326
|
-
placeholderColor={theme.textMuted}
|
|
327
|
-
cursorColor={theme.text}
|
|
328
|
-
backgroundColor="transparent"
|
|
329
|
-
focusedBackgroundColor="transparent"
|
|
330
|
-
flexGrow={1}
|
|
331
|
-
/>
|
|
332
|
-
{pop.ghost && active && rows === 1 ? (
|
|
333
|
-
<box position="absolute" top={0} left={2 + input.length} height={1}>
|
|
334
|
-
<text fg={theme.textMuted}>{pop.ghost}</text>
|
|
335
|
-
</box>
|
|
336
|
-
) : null}
|
|
337
|
-
</box>
|
|
338
|
-
|
|
339
|
-
<box height={1} flexDirection="row" paddingX={1}>
|
|
340
|
-
<text>
|
|
341
|
-
<span fg={dot}>● </span>
|
|
342
|
-
<span fg={theme.textMuted}>{label}</span>
|
|
343
|
-
</text>
|
|
344
|
-
</box>
|
|
345
|
-
</box>
|
|
346
|
-
)
|
|
347
|
-
}))
|
|
@@ -1,116 +0,0 @@
|
|
|
1
|
-
import { memo } from "react"
|
|
2
|
-
import { useTheme } from "../../theme"
|
|
3
|
-
|
|
4
|
-
// Strip ANSI escape sequences — the gateway's inline_diff may arrive
|
|
5
|
-
// pre-colored for a pty and OpenTUI rendering it as literal text
|
|
6
|
-
// produces garble plus our own theme-coloring applied on top.
|
|
7
|
-
// eslint-disable-next-line no-control-regex
|
|
8
|
-
const ANSI = /\x1b\[[0-9;?]*[A-Za-z]/g
|
|
9
|
-
|
|
10
|
-
/** Heuristic: unified-diff output from patch/edit tools. */
|
|
11
|
-
export function isDiff(s: string | undefined): boolean {
|
|
12
|
-
if (!s) return false
|
|
13
|
-
return /^--- a\//m.test(s) || /^@@ /m.test(s) || /^diff --git /m.test(s)
|
|
14
|
-
}
|
|
15
|
-
|
|
16
|
-
const CAP = 80
|
|
17
|
-
const WORD_CAP = 40
|
|
18
|
-
|
|
19
|
-
type Seg = { text: string; hi: boolean }
|
|
20
|
-
|
|
21
|
-
const tokens = (s: string) => s.split(/(\s+)/).filter(t => t.length > 0)
|
|
22
|
-
|
|
23
|
-
/** Common-prefix/suffix word diff. Returns segments for (old, new). */
|
|
24
|
-
export function wordDiff(a: string, b: string): [Seg[], Seg[]] {
|
|
25
|
-
const ta = tokens(a)
|
|
26
|
-
const tb = tokens(b)
|
|
27
|
-
let p = 0
|
|
28
|
-
while (p < ta.length && p < tb.length && ta[p] === tb[p]) p++
|
|
29
|
-
let s = 0
|
|
30
|
-
while (s < ta.length - p && s < tb.length - p && ta[ta.length - 1 - s] === tb[tb.length - 1 - s]) s++
|
|
31
|
-
const seg = (t: string[]) => {
|
|
32
|
-
const mid = t.slice(p, t.length - s).join("")
|
|
33
|
-
const out: Seg[] = []
|
|
34
|
-
if (p) out.push({ text: t.slice(0, p).join(""), hi: false })
|
|
35
|
-
if (mid) out.push({ text: mid, hi: true })
|
|
36
|
-
if (s) out.push({ text: t.slice(t.length - s).join(""), hi: false })
|
|
37
|
-
return out
|
|
38
|
-
}
|
|
39
|
-
return [seg(ta), seg(tb)]
|
|
40
|
-
}
|
|
41
|
-
|
|
42
|
-
/**
|
|
43
|
-
* Pair runs of `-` lines immediately followed by `+` lines index-wise
|
|
44
|
-
* and compute intra-line word segments. Returns null for lines that
|
|
45
|
-
* stay whole-line colored. Skipped entirely above WORD_CAP.
|
|
46
|
-
*/
|
|
47
|
-
export function intraline(rows: string[]): (Seg[] | null)[] {
|
|
48
|
-
const marks: (Seg[] | null)[] = rows.map(() => null)
|
|
49
|
-
if (rows.length > WORD_CAP) return marks
|
|
50
|
-
const del = (l: string) => l.startsWith("-") && !l.startsWith("---")
|
|
51
|
-
const add = (l: string) => l.startsWith("+") && !l.startsWith("+++")
|
|
52
|
-
let i = 0
|
|
53
|
-
while (i < rows.length) {
|
|
54
|
-
if (!del(rows[i])) { i++; continue }
|
|
55
|
-
let j = i
|
|
56
|
-
while (j < rows.length && del(rows[j])) j++
|
|
57
|
-
let k = j
|
|
58
|
-
while (k < rows.length && add(rows[k])) k++
|
|
59
|
-
const n = Math.min(j - i, k - j)
|
|
60
|
-
for (let d = 0; d < n; d++) {
|
|
61
|
-
const [rm, ad] = wordDiff(rows[i + d].slice(1), rows[j + d].slice(1))
|
|
62
|
-
marks[i + d] = rm
|
|
63
|
-
marks[j + d] = ad
|
|
64
|
-
}
|
|
65
|
-
i = k
|
|
66
|
-
}
|
|
67
|
-
return marks
|
|
68
|
-
}
|
|
69
|
-
|
|
70
|
-
/**
|
|
71
|
-
* Line-colored unified diff. OpenTUI ships a native `<diff>` renderable
|
|
72
|
-
* (split/unified, line numbers), but it manages its own scroll regions
|
|
73
|
-
* and height — heavy for an inline preview nested inside the chat
|
|
74
|
-
* scrollbox. This block renders one `<text>` per line with theme colors
|
|
75
|
-
* and a hard 80-line cap so layout stays stable. For small diffs
|
|
76
|
-
* (≤40 lines) paired -/+ change lines get word-level bg highlights.
|
|
77
|
-
*/
|
|
78
|
-
export const DiffBlock = memo(({ text }: { text: string }) => {
|
|
79
|
-
const theme = useTheme().theme
|
|
80
|
-
const all = text.replace(ANSI, "").replace(/\n$/, "").split("\n")
|
|
81
|
-
const rows = all.slice(0, CAP)
|
|
82
|
-
const more = all.length - rows.length
|
|
83
|
-
const marks = intraline(rows)
|
|
84
|
-
|
|
85
|
-
const fg = (l: string) =>
|
|
86
|
-
l.startsWith("@@") ? theme.accent
|
|
87
|
-
: l.startsWith("+++") || l.startsWith("---") ? theme.textMuted
|
|
88
|
-
: l.startsWith("+") ? theme.success
|
|
89
|
-
: l.startsWith("-") ? theme.error
|
|
90
|
-
: theme.textMuted
|
|
91
|
-
|
|
92
|
-
return (
|
|
93
|
-
<box flexDirection="column" backgroundColor={theme.backgroundPanel}>
|
|
94
|
-
{rows.map((l, i) => {
|
|
95
|
-
const segs = marks[i]
|
|
96
|
-
const bg = l.startsWith("+") ? theme.diffAddedBg : theme.diffRemovedBg
|
|
97
|
-
return (
|
|
98
|
-
<box key={i} height={1} overflow="hidden" minWidth={0}>
|
|
99
|
-
<text fg={fg(l)}>
|
|
100
|
-
{segs
|
|
101
|
-
? <>{l[0]}{segs.map((s, j) => s.hi
|
|
102
|
-
? <span key={j} bg={bg}>{s.text}</span>
|
|
103
|
-
: s.text)}</>
|
|
104
|
-
: l || " "}
|
|
105
|
-
</text>
|
|
106
|
-
</box>
|
|
107
|
-
)
|
|
108
|
-
})}
|
|
109
|
-
{more > 0 ? (
|
|
110
|
-
<box height={1}>
|
|
111
|
-
<text fg={theme.textMuted}>… {more} more lines</text>
|
|
112
|
-
</box>
|
|
113
|
-
) : null}
|
|
114
|
-
</box>
|
|
115
|
-
)
|
|
116
|
-
})
|
|
@@ -1,70 +0,0 @@
|
|
|
1
|
-
// Turn-level error card — a failed assistant turn (API error, agent
|
|
2
|
-
// crash) renders this in place of the ✗ one-liner. Uses the same
|
|
3
|
-
// panel grammar as BlockTool (┃ left bar, backgroundPanel) so it
|
|
4
|
-
// reads as "a block in the trail that happens to be red."
|
|
5
|
-
|
|
6
|
-
import { memo, useEffect, useState } from "react"
|
|
7
|
-
import { LEFT_BAR } from "../../ui/borders"
|
|
8
|
-
import { useTheme } from "../../theme"
|
|
9
|
-
import { copy } from "../../utils/clipboard"
|
|
10
|
-
|
|
11
|
-
const CAP = 6
|
|
12
|
-
|
|
13
|
-
export const ErrorBlock = memo(({ text }: { text: string }) => {
|
|
14
|
-
const theme = useTheme().theme
|
|
15
|
-
const [open, setOpen] = useState(false)
|
|
16
|
-
const [copied, setCopied] = useState(false)
|
|
17
|
-
|
|
18
|
-
const lines = text.trimEnd().split("\n")
|
|
19
|
-
const head = lines[0] || "Error"
|
|
20
|
-
const body = lines.slice(1)
|
|
21
|
-
const over = body.length > CAP
|
|
22
|
-
const shown = open || !over ? body : body.slice(0, CAP)
|
|
23
|
-
|
|
24
|
-
useEffect(() => {
|
|
25
|
-
if (!copied) return
|
|
26
|
-
const t = setTimeout(() => setCopied(false), 1500)
|
|
27
|
-
return () => clearTimeout(t)
|
|
28
|
-
}, [copied])
|
|
29
|
-
|
|
30
|
-
const doCopy = () => {
|
|
31
|
-
void copy(text)
|
|
32
|
-
setCopied(true)
|
|
33
|
-
}
|
|
34
|
-
|
|
35
|
-
return (
|
|
36
|
-
<box
|
|
37
|
-
border={["left"]}
|
|
38
|
-
borderColor={theme.error}
|
|
39
|
-
customBorderChars={LEFT_BAR}
|
|
40
|
-
backgroundColor={theme.backgroundPanel}
|
|
41
|
-
paddingLeft={2}
|
|
42
|
-
paddingTop={1}
|
|
43
|
-
paddingBottom={1}
|
|
44
|
-
marginTop={1}
|
|
45
|
-
flexDirection="column"
|
|
46
|
-
gap={1}
|
|
47
|
-
>
|
|
48
|
-
<box flexDirection="row" height={1}>
|
|
49
|
-
<box flexGrow={1}>
|
|
50
|
-
<text><span fg={theme.error}>✗ </span><span fg={theme.text}>{head}</span></text>
|
|
51
|
-
</box>
|
|
52
|
-
<box onMouseDown={doCopy} paddingX={1}>
|
|
53
|
-
<text fg={copied ? theme.success : theme.textMuted}>{copied ? "copied" : "copy"}</text>
|
|
54
|
-
</box>
|
|
55
|
-
</box>
|
|
56
|
-
{shown.length ? (
|
|
57
|
-
<box flexDirection="column" onMouseDown={over ? () => setOpen(o => !o) : undefined}>
|
|
58
|
-
{shown.map((l, i) => (
|
|
59
|
-
<box key={i} height={1}><text fg={theme.textMuted}>{l || " "}</text></box>
|
|
60
|
-
))}
|
|
61
|
-
{over ? (
|
|
62
|
-
<box height={1}>
|
|
63
|
-
<text fg={theme.textMuted}>{open ? "Click to collapse" : `… ${body.length - CAP} more — click to expand`}</text>
|
|
64
|
-
</box>
|
|
65
|
-
) : null}
|
|
66
|
-
</box>
|
|
67
|
-
) : null}
|
|
68
|
-
</box>
|
|
69
|
-
)
|
|
70
|
-
})
|