Merge branch 'feat/ink-refactor' of github.com:NousResearch/hermes-agent into feat/ink-refactor
This commit is contained in:
@@ -1,6 +1,6 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import { fmtK, isToolTrailResultLine, lastCotTrailIndex, sameToolTrailGroup } from '../lib/text.js'
|
||||
import { estimateRows, fmtK, isToolTrailResultLine, lastCotTrailIndex, sameToolTrailGroup } from '../lib/text.js'
|
||||
|
||||
describe('isToolTrailResultLine', () => {
|
||||
it('detects completion markers', () => {
|
||||
@@ -49,3 +49,17 @@ describe('fmtK', () => {
|
||||
expect(fmtK(1_000_000_000)).toBe('1B')
|
||||
})
|
||||
})
|
||||
|
||||
describe('estimateRows', () => {
|
||||
it('handles tilde code fences', () => {
|
||||
const md = ['~~~markdown', '# heading', '~~~'].join('\n')
|
||||
|
||||
expect(estimateRows(md, 40)).toBeGreaterThanOrEqual(2)
|
||||
})
|
||||
|
||||
it('handles checklist bullets as list rows', () => {
|
||||
const md = ['- [x] done', '- [ ] todo'].join('\n')
|
||||
|
||||
expect(estimateRows(md, 40)).toBe(2)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1594,12 +1594,17 @@ export function App({ gw }: { gw: GatewayClient }) {
|
||||
if (!pastes.length) {
|
||||
sys('no text pastes')
|
||||
} else {
|
||||
panel('Paste Shelf', [{
|
||||
rows: pastes.map(p => [
|
||||
`#${p.id} ${p.mode}`,
|
||||
`${p.lineCount}L · ${p.kind} · ${compactPreview(p.text, 60) || '(empty)'}`
|
||||
] as [string, string])
|
||||
}])
|
||||
panel('Paste Shelf', [
|
||||
{
|
||||
rows: pastes.map(
|
||||
p =>
|
||||
[
|
||||
`#${p.id} ${p.mode}`,
|
||||
`${p.lineCount}L · ${p.kind} · ${compactPreview(p.text, 60) || '(empty)'}`
|
||||
] as [string, string]
|
||||
)
|
||||
}
|
||||
])
|
||||
}
|
||||
|
||||
return true
|
||||
@@ -1652,7 +1657,6 @@ export function App({ gw }: { gw: GatewayClient }) {
|
||||
sys('usage: /paste [list|mode <id> <attach|excerpt|inline>|drop <id>|clear]')
|
||||
|
||||
return true
|
||||
|
||||
case 'logs': {
|
||||
const logText = gw.getLogTail(Math.min(80, Math.max(1, parseInt(arg, 10) || 20)))
|
||||
logText ? page(logText, 'Logs') : sys('no gateway logs')
|
||||
@@ -1765,7 +1769,14 @@ export function App({ gw }: { gw: GatewayClient }) {
|
||||
case 'model':
|
||||
if (!arg) {
|
||||
rpc('config.get', { key: 'provider' }).then((r: any) =>
|
||||
panel('Model', [{ rows: [['Model', r.model], ['Provider', r.provider]] }])
|
||||
panel('Model', [
|
||||
{
|
||||
rows: [
|
||||
['Model', r.model],
|
||||
['Provider', r.provider]
|
||||
]
|
||||
}
|
||||
])
|
||||
)
|
||||
} else {
|
||||
rpc('config.set', { session_id: sid, key: 'model', value: arg.replace('--global', '').trim() }).then(
|
||||
@@ -1900,6 +1911,7 @@ export function App({ gw }: { gw: GatewayClient }) {
|
||||
}
|
||||
|
||||
const f = (v: number) => (v ?? 0).toLocaleString()
|
||||
|
||||
const cost =
|
||||
r.cost_usd != null ? `${r.cost_status === 'estimated' ? '~' : ''}$${r.cost_usd.toFixed(4)}` : null
|
||||
|
||||
@@ -1913,7 +1925,9 @@ export function App({ gw }: { gw: GatewayClient }) {
|
||||
['API calls', f(r.calls)]
|
||||
]
|
||||
|
||||
if (cost) rows.push(['Cost', cost])
|
||||
if (cost) {
|
||||
rows.push(['Cost', cost])
|
||||
}
|
||||
|
||||
const sections: PanelSection[] = [{ rows }]
|
||||
|
||||
@@ -1921,7 +1935,9 @@ export function App({ gw }: { gw: GatewayClient }) {
|
||||
sections.push({ text: `Context: ${f(r.context_used)} / ${f(r.context_max)} (${r.context_percent}%)` })
|
||||
}
|
||||
|
||||
if (r.compressions) sections.push({ text: `Compressions: ${r.compressions}` })
|
||||
if (r.compressions) {
|
||||
sections.push({ text: `Compressions: ${r.compressions}` })
|
||||
}
|
||||
|
||||
panel('Usage', sections)
|
||||
})
|
||||
@@ -1966,13 +1982,15 @@ export function App({ gw }: { gw: GatewayClient }) {
|
||||
|
||||
case 'insights':
|
||||
rpc('insights.get', { days: parseInt(arg) || 30 }).then((r: any) =>
|
||||
panel('Insights', [{
|
||||
rows: [
|
||||
['Period', `${r.days} days`],
|
||||
['Sessions', `${r.sessions}`],
|
||||
['Messages', `${r.messages}`]
|
||||
]
|
||||
}])
|
||||
panel('Insights', [
|
||||
{
|
||||
rows: [
|
||||
['Period', `${r.days} days`],
|
||||
['Sessions', `${r.sessions}`],
|
||||
['Messages', `${r.messages}`]
|
||||
]
|
||||
}
|
||||
])
|
||||
)
|
||||
|
||||
return true
|
||||
@@ -1985,12 +2003,13 @@ export function App({ gw }: { gw: GatewayClient }) {
|
||||
return sys('no checkpoints')
|
||||
}
|
||||
|
||||
panel('Checkpoints', [{
|
||||
rows: r.checkpoints.map((c: any, i: number) => [
|
||||
`${i + 1} ${c.hash?.slice(0, 8)}`,
|
||||
c.message
|
||||
] as [string, string])
|
||||
}])
|
||||
panel('Checkpoints', [
|
||||
{
|
||||
rows: r.checkpoints.map(
|
||||
(c: any, i: number) => [`${i + 1} ${c.hash?.slice(0, 8)}`, c.message] as [string, string]
|
||||
)
|
||||
}
|
||||
])
|
||||
})
|
||||
} else {
|
||||
const hash = sub === 'restore' || sub === 'diff' ? rArgs[0] : sub
|
||||
@@ -2023,9 +2042,11 @@ export function App({ gw }: { gw: GatewayClient }) {
|
||||
return sys('no plugins')
|
||||
}
|
||||
|
||||
panel('Plugins', [{
|
||||
items: r.plugins.map((p: any) => `${p.name} v${p.version}${p.enabled ? '' : ' (disabled)'}`)
|
||||
}])
|
||||
panel('Plugins', [
|
||||
{
|
||||
items: r.plugins.map((p: any) => `${p.name} v${p.version}${p.enabled ? '' : ' (disabled)'}`)
|
||||
}
|
||||
])
|
||||
})
|
||||
|
||||
return true
|
||||
@@ -2040,10 +2061,13 @@ export function App({ gw }: { gw: GatewayClient }) {
|
||||
return sys('no skills installed')
|
||||
}
|
||||
|
||||
panel('Installed Skills', Object.entries(sk).map(([cat, names]) => ({
|
||||
title: cat,
|
||||
items: names as string[]
|
||||
})))
|
||||
panel(
|
||||
'Installed Skills',
|
||||
Object.entries(sk).map(([cat, names]) => ({
|
||||
title: cat,
|
||||
items: names as string[]
|
||||
}))
|
||||
)
|
||||
})
|
||||
|
||||
return true
|
||||
@@ -2052,17 +2076,29 @@ export function App({ gw }: { gw: GatewayClient }) {
|
||||
if (sub === 'browse') {
|
||||
const pg = parseInt(sArgs[0] ?? '1', 10) || 1
|
||||
rpc('skills.manage', { action: 'browse', page: pg }).then((r: any) => {
|
||||
if (!r.items?.length) return sys('no skills found in the hub')
|
||||
if (!r.items?.length) {
|
||||
return sys('no skills found in the hub')
|
||||
}
|
||||
|
||||
const sections: PanelSection[] = [{
|
||||
rows: r.items.map((s: any) => [
|
||||
s.name ?? '',
|
||||
(s.description ?? '').slice(0, 60) + (s.description?.length > 60 ? '…' : '')
|
||||
] as [string, string])
|
||||
}]
|
||||
const sections: PanelSection[] = [
|
||||
{
|
||||
rows: r.items.map(
|
||||
(s: any) =>
|
||||
[s.name ?? '', (s.description ?? '').slice(0, 60) + (s.description?.length > 60 ? '…' : '')] as [
|
||||
string,
|
||||
string
|
||||
]
|
||||
)
|
||||
}
|
||||
]
|
||||
|
||||
if (r.page < r.total_pages) sections.push({ text: `/skills browse ${r.page + 1} → next page` })
|
||||
if (r.page > 1) sections.push({ text: `/skills browse ${r.page - 1} → prev page` })
|
||||
if (r.page < r.total_pages) {
|
||||
sections.push({ text: `/skills browse ${r.page + 1} → next page` })
|
||||
}
|
||||
|
||||
if (r.page > 1) {
|
||||
sections.push({ text: `/skills browse ${r.page - 1} → prev page` })
|
||||
}
|
||||
|
||||
panel(`Skills Hub (page ${r.page}/${r.total_pages}, ${r.total} total)`, sections)
|
||||
})
|
||||
@@ -2080,47 +2116,57 @@ export function App({ gw }: { gw: GatewayClient }) {
|
||||
case 'agents':
|
||||
|
||||
case 'tasks':
|
||||
rpc('agents.list', {}).then((r: any) => {
|
||||
const procs = r.processes ?? []
|
||||
const running = procs.filter((p: any) => p.status === 'running')
|
||||
const finished = procs.filter((p: any) => p.status !== 'running')
|
||||
const sections: PanelSection[] = []
|
||||
rpc('agents.list', {})
|
||||
.then((r: any) => {
|
||||
const procs = r.processes ?? []
|
||||
const running = procs.filter((p: any) => p.status === 'running')
|
||||
const finished = procs.filter((p: any) => p.status !== 'running')
|
||||
const sections: PanelSection[] = []
|
||||
|
||||
if (running.length) {
|
||||
sections.push({
|
||||
title: `Running (${running.length})`,
|
||||
rows: running.map((p: any) => [p.session_id.slice(0, 8), p.command])
|
||||
})
|
||||
}
|
||||
if (running.length) {
|
||||
sections.push({
|
||||
title: `Running (${running.length})`,
|
||||
rows: running.map((p: any) => [p.session_id.slice(0, 8), p.command])
|
||||
})
|
||||
}
|
||||
|
||||
if (finished.length) {
|
||||
sections.push({
|
||||
title: `Finished (${finished.length})`,
|
||||
rows: finished.map((p: any) => [p.session_id.slice(0, 8), p.command])
|
||||
})
|
||||
}
|
||||
if (finished.length) {
|
||||
sections.push({
|
||||
title: `Finished (${finished.length})`,
|
||||
rows: finished.map((p: any) => [p.session_id.slice(0, 8), p.command])
|
||||
})
|
||||
}
|
||||
|
||||
if (!sections.length) sections.push({ text: 'No active processes' })
|
||||
if (!sections.length) {
|
||||
sections.push({ text: 'No active processes' })
|
||||
}
|
||||
|
||||
panel('Agents', sections)
|
||||
}).catch(() => sys('agents command failed'))
|
||||
panel('Agents', sections)
|
||||
})
|
||||
.catch(() => sys('agents command failed'))
|
||||
|
||||
return true
|
||||
|
||||
case 'cron':
|
||||
if (!arg || arg === 'list') {
|
||||
rpc('cron.manage', { action: 'list' }).then((r: any) => {
|
||||
const jobs = r.jobs ?? []
|
||||
rpc('cron.manage', { action: 'list' })
|
||||
.then((r: any) => {
|
||||
const jobs = r.jobs ?? []
|
||||
|
||||
if (!jobs.length) return sys('no scheduled jobs')
|
||||
if (!jobs.length) {
|
||||
return sys('no scheduled jobs')
|
||||
}
|
||||
|
||||
panel('Cron', [{
|
||||
rows: jobs.map((j: any) => [
|
||||
j.name || j.job_id?.slice(0, 12),
|
||||
`${j.schedule} · ${j.state ?? 'active'}`
|
||||
] as [string, string])
|
||||
}])
|
||||
}).catch(() => sys('cron command failed'))
|
||||
panel('Cron', [
|
||||
{
|
||||
rows: jobs.map(
|
||||
(j: any) =>
|
||||
[j.name || j.job_id?.slice(0, 12), `${j.schedule} · ${j.state ?? 'active'}`] as [string, string]
|
||||
)
|
||||
}
|
||||
])
|
||||
})
|
||||
.catch(() => sys('cron command failed'))
|
||||
} else {
|
||||
gw.request('slash.exec', { command: cmd.slice(1), session_id: sid })
|
||||
.then((r: any) => sys(r?.output || '(no output)'))
|
||||
@@ -2130,38 +2176,59 @@ export function App({ gw }: { gw: GatewayClient }) {
|
||||
return true
|
||||
|
||||
case 'config':
|
||||
rpc('config.show', {}).then((r: any) => {
|
||||
panel('Config', (r.sections ?? []).map((s: any) => ({
|
||||
title: s.title,
|
||||
rows: s.rows
|
||||
})))
|
||||
}).catch(() => sys('config command failed'))
|
||||
rpc('config.show', {})
|
||||
.then((r: any) => {
|
||||
panel(
|
||||
'Config',
|
||||
(r.sections ?? []).map((s: any) => ({
|
||||
title: s.title,
|
||||
rows: s.rows
|
||||
}))
|
||||
)
|
||||
})
|
||||
.catch(() => sys('config command failed'))
|
||||
|
||||
return true
|
||||
|
||||
case 'tools':
|
||||
rpc('tools.list', { session_id: sid }).then((r: any) => {
|
||||
if (!r.toolsets?.length) return sys('no tools')
|
||||
rpc('tools.list', { session_id: sid })
|
||||
.then((r: any) => {
|
||||
if (!r.toolsets?.length) {
|
||||
return sys('no tools')
|
||||
}
|
||||
|
||||
panel('Tools', r.toolsets.map((ts: any) => ({
|
||||
title: `${ts.enabled ? '*' : ' '} ${ts.name} [${ts.tool_count} tools]`,
|
||||
items: ts.tools
|
||||
})))
|
||||
}).catch(() => sys('tools command failed'))
|
||||
panel(
|
||||
'Tools',
|
||||
r.toolsets.map((ts: any) => ({
|
||||
title: `${ts.enabled ? '*' : ' '} ${ts.name} [${ts.tool_count} tools]`,
|
||||
items: ts.tools
|
||||
}))
|
||||
)
|
||||
})
|
||||
.catch(() => sys('tools command failed'))
|
||||
|
||||
return true
|
||||
|
||||
case 'toolsets':
|
||||
rpc('toolsets.list', { session_id: sid }).then((r: any) => {
|
||||
if (!r.toolsets?.length) return sys('no toolsets')
|
||||
rpc('toolsets.list', { session_id: sid })
|
||||
.then((r: any) => {
|
||||
if (!r.toolsets?.length) {
|
||||
return sys('no toolsets')
|
||||
}
|
||||
|
||||
panel('Toolsets', [{
|
||||
rows: r.toolsets.map((ts: any) => [
|
||||
`${ts.enabled ? '(*)' : ' '} ${ts.name}`,
|
||||
`[${ts.tool_count}] ${ts.description}`
|
||||
] as [string, string])
|
||||
}])
|
||||
}).catch(() => sys('toolsets command failed'))
|
||||
panel('Toolsets', [
|
||||
{
|
||||
rows: r.toolsets.map(
|
||||
(ts: any) =>
|
||||
[`${ts.enabled ? '(*)' : ' '} ${ts.name}`, `[${ts.tool_count}] ${ts.description}`] as [
|
||||
string,
|
||||
string
|
||||
]
|
||||
)
|
||||
}
|
||||
])
|
||||
})
|
||||
.catch(() => sys('toolsets command failed'))
|
||||
|
||||
return true
|
||||
|
||||
@@ -2188,7 +2255,23 @@ export function App({ gw }: { gw: GatewayClient }) {
|
||||
return true
|
||||
}
|
||||
},
|
||||
[catalog, compact, gw, lastUserMsg, messages, newSession, page, panel, pastes, pushActivity, rpc, send, sid, statusBar, sys]
|
||||
[
|
||||
catalog,
|
||||
compact,
|
||||
gw,
|
||||
lastUserMsg,
|
||||
messages,
|
||||
newSession,
|
||||
page,
|
||||
panel,
|
||||
pastes,
|
||||
pushActivity,
|
||||
rpc,
|
||||
send,
|
||||
sid,
|
||||
statusBar,
|
||||
sys
|
||||
]
|
||||
)
|
||||
|
||||
slashRef.current = slash
|
||||
|
||||
@@ -179,4 +179,3 @@ export function Panel({ sections, t, title }: { sections: PanelSection[]; t: The
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -3,17 +3,104 @@ import type { ReactNode } from 'react'
|
||||
|
||||
import type { Theme } from '../theme.js'
|
||||
|
||||
/** OSC 8 hyperlink — wrap-ansi / Ink keep the link active across soft line wraps. */
|
||||
const osc8 = (url: string) => '\x1b]8;;' + url + '\x1b\\'
|
||||
const OSC8_END = '\x1b]8;;\x1b\\'
|
||||
const FENCE_RE = /^\s*(`{3,}|~{3,})(.*)$/
|
||||
const HR_RE = /^ {0,3}([-*_])(?:\s*\1){2,}\s*$/
|
||||
const HEADING_RE = /^\s{0,3}(#{1,6})\s+(.*?)(?:\s+#+\s*)?$/
|
||||
const FOOTNOTE_RE = /^\[\^([^\]]+)\]:\s*(.*)$/
|
||||
const DEF_RE = /^\s*:\s+(.+)$/
|
||||
const TABLE_DIVIDER_CELL_RE = /^:?-{3,}:?$/
|
||||
const MD_URL_RE = '((?:[^\\s()]|\\([^\\s()]*\\))+?)'
|
||||
const INLINE_RE =
|
||||
new RegExp(
|
||||
`(!\\[(.*?)\\]\\(${MD_URL_RE}\\)|\\[(.+?)\\]\\(${MD_URL_RE}\\)|<((?:https?:\\/\\/|mailto:)[^>\\s]+|[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\\.[A-Za-z]{2,})>|~~(.+?)~~|\`([^\\\`]+)\`|\\*\\*(.+?)\\*\\*|__(.+?)__|\\*(.+?)\\*|_(.+?)_|==(.+?)==|\\[\\^([^\\]]+)\\]|\\^([^^\\s][^^]*?)\\^|~([^~\\s][^~]*?)~|(https?:\\/\\/[^\\s<]+))`,
|
||||
'g'
|
||||
)
|
||||
|
||||
type Fence = {
|
||||
char: '`' | '~'
|
||||
lang: string
|
||||
len: number
|
||||
}
|
||||
|
||||
const renderLink = (key: number, t: Theme, label: string) => (
|
||||
<Text color={t.color.amber} key={key} underline>
|
||||
{label}
|
||||
</Text>
|
||||
)
|
||||
|
||||
const trimBareUrl = (value: string) => {
|
||||
const trimmed = value.replace(/[),.;:!?]+$/g, '')
|
||||
|
||||
return {
|
||||
tail: value.slice(trimmed.length),
|
||||
url: trimmed
|
||||
}
|
||||
}
|
||||
|
||||
const renderAutolink = (key: number, t: Theme, raw: string) => (
|
||||
<Text color={t.color.amber} key={key} underline>
|
||||
{raw.replace(/^mailto:/, '')}
|
||||
</Text>
|
||||
)
|
||||
|
||||
const indentDepth = (indent: string) => Math.floor(indent.replace(/\t/g, ' ').length / 2)
|
||||
|
||||
const parseFence = (line: string): Fence | null => {
|
||||
const m = line.match(FENCE_RE)
|
||||
|
||||
if (!m) {
|
||||
return null
|
||||
}
|
||||
|
||||
return {
|
||||
char: m[1]![0] as '`' | '~',
|
||||
lang: m[2]!.trim().toLowerCase(),
|
||||
len: m[1]!.length
|
||||
}
|
||||
}
|
||||
|
||||
const isFenceClose = (line: string, fence: Fence) => {
|
||||
const end = line.match(/^\s*(`{3,}|~{3,})\s*$/)
|
||||
|
||||
return Boolean(end && end[1]![0] === fence.char && end[1]!.length >= fence.len)
|
||||
}
|
||||
|
||||
const isMarkdownFence = (lang: string) => ['md', 'markdown'].includes(lang)
|
||||
|
||||
const splitTableRow = (row: string) =>
|
||||
row
|
||||
.trim()
|
||||
.replace(/^\|/, '')
|
||||
.replace(/\|$/, '')
|
||||
.split('|')
|
||||
.map(cell => cell.trim())
|
||||
|
||||
const isTableDivider = (row: string) => {
|
||||
const cells = splitTableRow(row)
|
||||
|
||||
return cells.length > 1 && cells.every(cell => TABLE_DIVIDER_CELL_RE.test(cell))
|
||||
}
|
||||
|
||||
const renderTable = (key: number, rows: string[][], t: Theme) => {
|
||||
const widths = rows[0]!.map((_, ci) => Math.max(...rows.map(r => (r[ci] ?? '').length)))
|
||||
|
||||
return (
|
||||
<Box flexDirection="column" key={key} paddingLeft={2}>
|
||||
{rows.map((row, ri) => (
|
||||
<Text color={ri === 0 ? t.color.amber : undefined} key={ri}>
|
||||
{row.map((cell, ci) => cell.padEnd(widths[ci] ?? 0)).join(' ')}
|
||||
</Text>
|
||||
))}
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
|
||||
function MdInline({ t, text }: { t: Theme; text: string }) {
|
||||
const parts: ReactNode[] = []
|
||||
const re = /(\[(.+?)\]\((https?:\/\/[^\s)]+)\)|\*\*(.+?)\*\*|`([^`]+)`|\*(.+?)\*|(https?:\/\/[^\s]+))/g
|
||||
|
||||
let last = 0
|
||||
|
||||
for (const m of text.matchAll(re)) {
|
||||
for (const m of text.matchAll(INLINE_RE)) {
|
||||
const i = m.index ?? 0
|
||||
|
||||
if (i > last) {
|
||||
@@ -22,43 +109,74 @@ function MdInline({ t, text }: { t: Theme; text: string }) {
|
||||
|
||||
if (m[2] && m[3]) {
|
||||
parts.push(
|
||||
<Text key={parts.length}>
|
||||
{osc8(m[3])}
|
||||
<Text color={t.color.amber} underline>
|
||||
{m[2]}
|
||||
</Text>
|
||||
{OSC8_END}
|
||||
<Text color={t.color.dim} key={parts.length}>
|
||||
[image: {m[2]}] {m[3]}
|
||||
</Text>
|
||||
)
|
||||
} else if (m[4]) {
|
||||
} else if (m[4] && m[5]) {
|
||||
parts.push(renderLink(parts.length, t, m[4]))
|
||||
} else if (m[6]) {
|
||||
parts.push(renderAutolink(parts.length, t, m[6]))
|
||||
} else if (m[7]) {
|
||||
parts.push(
|
||||
<Text bold key={parts.length}>
|
||||
{m[4]}
|
||||
<Text key={parts.length} strikethrough>
|
||||
{m[7]}
|
||||
</Text>
|
||||
)
|
||||
} else if (m[5]) {
|
||||
} else if (m[8]) {
|
||||
parts.push(
|
||||
<Text color={t.color.amber} dimColor key={parts.length}>
|
||||
{m[5]}
|
||||
{m[8]}
|
||||
</Text>
|
||||
)
|
||||
} else if (m[6]) {
|
||||
} else if (m[9] || m[10]) {
|
||||
parts.push(
|
||||
<Text bold key={parts.length}>
|
||||
{m[9] ?? m[10]}
|
||||
</Text>
|
||||
)
|
||||
} else if (m[11] || m[12]) {
|
||||
parts.push(
|
||||
<Text italic key={parts.length}>
|
||||
{m[6]}
|
||||
{m[11] ?? m[12]}
|
||||
</Text>
|
||||
)
|
||||
} else if (m[7]) {
|
||||
const u = m[7]
|
||||
} else if (m[13]) {
|
||||
parts.push(
|
||||
<Text key={parts.length}>
|
||||
{osc8(u)}
|
||||
<Text color={t.color.amber} underline>
|
||||
{u}
|
||||
</Text>
|
||||
{OSC8_END}
|
||||
<Text backgroundColor={t.color.diffAdded} color={t.color.diffAddedWord} key={parts.length}>
|
||||
{m[13]}
|
||||
</Text>
|
||||
)
|
||||
} else if (m[14]) {
|
||||
parts.push(
|
||||
<Text color={t.color.dim} key={parts.length}>
|
||||
[{m[14]}]
|
||||
</Text>
|
||||
)
|
||||
} else if (m[15]) {
|
||||
parts.push(
|
||||
<Text color={t.color.dim} key={parts.length}>
|
||||
^{m[15]}
|
||||
</Text>
|
||||
)
|
||||
} else if (m[16]) {
|
||||
parts.push(
|
||||
<Text color={t.color.dim} key={parts.length}>
|
||||
_{m[16]}
|
||||
</Text>
|
||||
)
|
||||
} else if (m[17]) {
|
||||
const { tail, url } = trimBareUrl(m[17])
|
||||
|
||||
parts.push(renderAutolink(parts.length, t, url))
|
||||
|
||||
if (tail) {
|
||||
parts.push(
|
||||
<Text key={parts.length}>
|
||||
{tail}
|
||||
</Text>
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
last = i + m[0].length
|
||||
@@ -75,7 +193,16 @@ export function Md({ compact, t, text }: { compact?: boolean; t: Theme; text: st
|
||||
const lines = text.split('\n')
|
||||
const nodes: ReactNode[] = []
|
||||
let i = 0
|
||||
let prevKind: 'blank' | 'code' | 'heading' | 'list' | 'paragraph' | 'quote' | 'table' | null = null
|
||||
let prevKind:
|
||||
| 'blank'
|
||||
| 'code'
|
||||
| 'heading'
|
||||
| 'list'
|
||||
| 'paragraph'
|
||||
| 'quote'
|
||||
| 'rule'
|
||||
| 'table'
|
||||
| null = null
|
||||
|
||||
const gap = () => {
|
||||
if (nodes.length && prevKind !== 'blank') {
|
||||
@@ -109,16 +236,29 @@ export function Md({ compact, t, text }: { compact?: boolean; t: Theme; text: st
|
||||
continue
|
||||
}
|
||||
|
||||
if (line.startsWith('```')) {
|
||||
start('code')
|
||||
const lang = line.slice(3).trim()
|
||||
const block: string[] = []
|
||||
const fence = parseFence(line)
|
||||
|
||||
for (i++; i < lines.length && !lines[i]!.startsWith('```'); i++) {
|
||||
if (fence) {
|
||||
const block: string[] = []
|
||||
const lang = fence.lang
|
||||
|
||||
for (i++; i < lines.length && !isFenceClose(lines[i]!, fence); i++) {
|
||||
block.push(lines[i]!)
|
||||
}
|
||||
|
||||
i++
|
||||
if (i < lines.length) {
|
||||
i++
|
||||
}
|
||||
|
||||
if (isMarkdownFence(lang)) {
|
||||
start('paragraph')
|
||||
nodes.push(<Md compact={compact} key={key} t={t} text={block.join('\n')} />)
|
||||
|
||||
continue
|
||||
}
|
||||
|
||||
start('code')
|
||||
|
||||
const isDiff = lang === 'diff'
|
||||
|
||||
nodes.push(
|
||||
@@ -146,13 +286,42 @@ export function Md({ compact, t, text }: { compact?: boolean; t: Theme; text: st
|
||||
continue
|
||||
}
|
||||
|
||||
const heading = line.match(/^#{1,3}\s+(.*)/)
|
||||
if (line.trim().startsWith('$$')) {
|
||||
start('code')
|
||||
|
||||
const block: string[] = []
|
||||
|
||||
for (i++; i < lines.length; i++) {
|
||||
if (lines[i]!.trim().startsWith('$$')) {
|
||||
i++
|
||||
|
||||
break
|
||||
}
|
||||
|
||||
block.push(lines[i]!)
|
||||
}
|
||||
|
||||
nodes.push(
|
||||
<Box flexDirection="column" key={key} paddingLeft={2}>
|
||||
<Text color={t.color.dim}>─ math</Text>
|
||||
{block.map((l, j) => (
|
||||
<Text color={t.color.amber} key={j}>
|
||||
{l}
|
||||
</Text>
|
||||
))}
|
||||
</Box>
|
||||
)
|
||||
|
||||
continue
|
||||
}
|
||||
|
||||
const heading = line.match(HEADING_RE)
|
||||
|
||||
if (heading) {
|
||||
start('heading')
|
||||
nodes.push(
|
||||
<Text bold color={t.color.amber} key={key}>
|
||||
{heading[1]}
|
||||
{heading[2]}
|
||||
</Text>
|
||||
)
|
||||
i++
|
||||
@@ -160,14 +329,103 @@ export function Md({ compact, t, text }: { compact?: boolean; t: Theme; text: st
|
||||
continue
|
||||
}
|
||||
|
||||
const bullet = line.match(/^\s*[-*]\s(.*)/)
|
||||
if (i + 1 < lines.length && line.trim()) {
|
||||
const setext = lines[i + 1]!.match(/^\s{0,3}(=+|-+)\s*$/)
|
||||
|
||||
if (setext) {
|
||||
start('heading')
|
||||
nodes.push(
|
||||
<Text bold color={t.color.amber} key={key}>
|
||||
{line.trim()}
|
||||
</Text>
|
||||
)
|
||||
i += 2
|
||||
|
||||
continue
|
||||
}
|
||||
}
|
||||
|
||||
if (HR_RE.test(line)) {
|
||||
start('rule')
|
||||
nodes.push(
|
||||
<Text color={t.color.dim} key={key}>
|
||||
{'─'.repeat(36)}
|
||||
</Text>
|
||||
)
|
||||
i++
|
||||
|
||||
continue
|
||||
}
|
||||
|
||||
const footnote = line.match(FOOTNOTE_RE)
|
||||
|
||||
if (footnote) {
|
||||
start('list')
|
||||
nodes.push(
|
||||
<Text color={t.color.dim} key={key}>
|
||||
[{footnote[1]}] <MdInline t={t} text={footnote[2] ?? ''} />
|
||||
</Text>
|
||||
)
|
||||
i++
|
||||
|
||||
while (i < lines.length && /^\s{2,}\S/.test(lines[i]!)) {
|
||||
nodes.push(
|
||||
<Box key={`${key}-cont-${i}`} paddingLeft={2}>
|
||||
<Text color={t.color.dim}>
|
||||
<MdInline t={t} text={lines[i]!.trim()} />
|
||||
</Text>
|
||||
</Box>
|
||||
)
|
||||
i++
|
||||
}
|
||||
|
||||
continue
|
||||
}
|
||||
|
||||
if (i + 1 < lines.length && DEF_RE.test(lines[i + 1]!)) {
|
||||
start('list')
|
||||
nodes.push(
|
||||
<Text bold key={key}>
|
||||
{line.trim()}
|
||||
</Text>
|
||||
)
|
||||
i++
|
||||
|
||||
while (i < lines.length) {
|
||||
const def = lines[i]!.match(DEF_RE)
|
||||
|
||||
if (!def) {
|
||||
break
|
||||
}
|
||||
|
||||
nodes.push(
|
||||
<Text key={`${key}-def-${i}`}>
|
||||
<Text color={t.color.dim}> · </Text>
|
||||
<MdInline t={t} text={def[1]!} />
|
||||
</Text>
|
||||
)
|
||||
i++
|
||||
}
|
||||
|
||||
continue
|
||||
}
|
||||
|
||||
const bullet = line.match(/^(\s*)[-+*]\s+(.*)$/)
|
||||
|
||||
if (bullet) {
|
||||
start('list')
|
||||
const depth = indentDepth(bullet[1]!)
|
||||
const task = bullet[2]!.match(/^\[( |x|X)\]\s+(.*)$/)
|
||||
const marker = task ? (task[1]!.toLowerCase() === 'x' ? '☑' : '☐') : '•'
|
||||
const body = task ? task[2]! : bullet[2]!
|
||||
|
||||
nodes.push(
|
||||
<Text key={key}>
|
||||
<Text color={t.color.dim}> • </Text>
|
||||
<MdInline t={t} text={bullet[1]!} />
|
||||
<Text color={t.color.dim}>
|
||||
{' '.repeat(depth * 2)}
|
||||
{marker}{' '}
|
||||
</Text>
|
||||
<MdInline t={t} text={body} />
|
||||
</Text>
|
||||
)
|
||||
i++
|
||||
@@ -175,14 +433,19 @@ export function Md({ compact, t, text }: { compact?: boolean; t: Theme; text: st
|
||||
continue
|
||||
}
|
||||
|
||||
const numbered = line.match(/^\s*(\d+)\.\s(.*)/)
|
||||
const numbered = line.match(/^(\s*)(\d+)[.)]\s+(.*)$/)
|
||||
|
||||
if (numbered) {
|
||||
start('list')
|
||||
const depth = indentDepth(numbered[1]!)
|
||||
|
||||
nodes.push(
|
||||
<Text key={key}>
|
||||
<Text color={t.color.dim}> {numbered[1]}. </Text>
|
||||
<MdInline t={t} text={numbered[2]!} />
|
||||
<Text color={t.color.dim}>
|
||||
{' '.repeat(depth * 2)}
|
||||
{numbered[2]}.{' '}
|
||||
</Text>
|
||||
<MdInline t={t} text={numbered[3]!} />
|
||||
</Text>
|
||||
)
|
||||
i++
|
||||
@@ -190,12 +453,18 @@ export function Md({ compact, t, text }: { compact?: boolean; t: Theme; text: st
|
||||
continue
|
||||
}
|
||||
|
||||
if (line.match(/^>\s?/)) {
|
||||
if (/^\s*(?:>\s*)+/.test(line)) {
|
||||
start('quote')
|
||||
const quoteLines: string[] = []
|
||||
const quoteLines: Array<{ depth: number; text: string }> = []
|
||||
|
||||
while (i < lines.length && lines[i]!.match(/^>\s?/)) {
|
||||
quoteLines.push(lines[i]!.replace(/^>\s?/, ''))
|
||||
while (i < lines.length && /^\s*(?:>\s*)+/.test(lines[i]!)) {
|
||||
const raw = lines[i]!
|
||||
const prefix = raw.match(/^\s*(?:>\s*)+/)?.[0] ?? ''
|
||||
|
||||
quoteLines.push({
|
||||
depth: (prefix.match(/>/g) ?? []).length,
|
||||
text: raw.slice(prefix.length)
|
||||
})
|
||||
i++
|
||||
}
|
||||
|
||||
@@ -203,8 +472,9 @@ export function Md({ compact, t, text }: { compact?: boolean; t: Theme; text: st
|
||||
<Box flexDirection="column" key={key}>
|
||||
{quoteLines.map((ql, qi) => (
|
||||
<Text color={t.color.dim} key={qi}>
|
||||
{' │ '}
|
||||
<MdInline t={t} text={ql} />
|
||||
{' '.repeat(Math.max(0, ql.depth - 1) * 2)}
|
||||
{'│ '}
|
||||
<MdInline t={t} text={ql.text} />
|
||||
</Text>
|
||||
))}
|
||||
</Box>
|
||||
@@ -213,6 +483,55 @@ export function Md({ compact, t, text }: { compact?: boolean; t: Theme; text: st
|
||||
continue
|
||||
}
|
||||
|
||||
if (line.includes('|') && i + 1 < lines.length && isTableDivider(lines[i + 1]!)) {
|
||||
start('table')
|
||||
const tableRows: string[][] = []
|
||||
|
||||
tableRows.push(splitTableRow(line))
|
||||
i += 2
|
||||
|
||||
while (i < lines.length && lines[i]!.includes('|') && lines[i]!.trim()) {
|
||||
tableRows.push(splitTableRow(lines[i]!))
|
||||
i++
|
||||
}
|
||||
|
||||
nodes.push(renderTable(key, tableRows, t))
|
||||
|
||||
continue
|
||||
}
|
||||
|
||||
if (/^<details\b/i.test(line) || /^<\/details>/i.test(line)) {
|
||||
i++
|
||||
|
||||
continue
|
||||
}
|
||||
|
||||
const summary = line.match(/^<summary>(.*?)<\/summary>$/i)
|
||||
|
||||
if (summary) {
|
||||
start('paragraph')
|
||||
nodes.push(
|
||||
<Text color={t.color.dim} key={key}>
|
||||
▶ {summary[1]}
|
||||
</Text>
|
||||
)
|
||||
i++
|
||||
|
||||
continue
|
||||
}
|
||||
|
||||
if (/^<\/?[^>]+>$/.test(line.trim())) {
|
||||
start('paragraph')
|
||||
nodes.push(
|
||||
<Text color={t.color.dim} key={key}>
|
||||
{line.trim()}
|
||||
</Text>
|
||||
)
|
||||
i++
|
||||
|
||||
continue
|
||||
}
|
||||
|
||||
if (line.includes('|') && line.trim().startsWith('|')) {
|
||||
start('table')
|
||||
const tableRows: string[][] = []
|
||||
@@ -221,29 +540,14 @@ export function Md({ compact, t, text }: { compact?: boolean; t: Theme; text: st
|
||||
const row = lines[i]!.trim()
|
||||
|
||||
if (!/^[|\s:-]+$/.test(row)) {
|
||||
tableRows.push(
|
||||
row
|
||||
.split('|')
|
||||
.filter(Boolean)
|
||||
.map(c => c.trim())
|
||||
)
|
||||
tableRows.push(splitTableRow(row))
|
||||
}
|
||||
|
||||
i++
|
||||
}
|
||||
|
||||
if (tableRows.length) {
|
||||
const widths = tableRows[0]!.map((_, ci) => Math.max(...tableRows.map(r => (r[ci] ?? '').length)))
|
||||
|
||||
nodes.push(
|
||||
<Box flexDirection="column" key={key} paddingLeft={2}>
|
||||
{tableRows.map((row, ri) => (
|
||||
<Text color={ri === 0 ? t.color.amber : undefined} key={ri}>
|
||||
{row.map((cell, ci) => cell.padEnd(widths[ci] ?? 0)).join(' ')}
|
||||
</Text>
|
||||
))}
|
||||
</Box>
|
||||
)
|
||||
nodes.push(renderTable(key, tableRows, t))
|
||||
}
|
||||
|
||||
continue
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import * as Ink from '@hermes/ink'
|
||||
import type { InputEvent, Key } from '@hermes/ink'
|
||||
import { useEffect, useMemo, useRef, useState } from 'react'
|
||||
|
||||
type InkExt = typeof Ink & {
|
||||
@@ -276,7 +277,7 @@ export function TextInput({ columns = 80, value, onChange, onPaste, onSubmit, pl
|
||||
// ── Input handler ────────────────────────────────────────────────
|
||||
|
||||
useInput(
|
||||
(inp, k, event) => {
|
||||
(inp: string, k: Key, event: InputEvent) => {
|
||||
// Some terminals normalize Ctrl+V to "v"; others deliver raw ^V (\x16).
|
||||
const ctrlPaste = k.ctrl && (inp.toLowerCase() === 'v' || event.keypress.raw === '\x16')
|
||||
const metaPaste = k.meta && inp.toLowerCase() === 'v'
|
||||
|
||||
@@ -24,10 +24,10 @@ export class GatewayClient extends EventEmitter {
|
||||
private pending = new Map<string, Pending>()
|
||||
|
||||
start() {
|
||||
const root = process.env.HERMES_ROOT ?? resolve(import.meta.dirname, '../../')
|
||||
const root = process.env.HERMES_PYTHON_SRC_ROOT ?? resolve(import.meta.dirname, '../../')
|
||||
|
||||
this.proc = spawn(process.env.HERMES_PYTHON ?? resolve(root, 'venv/bin/python'), ['-m', 'tui_gateway.entry'], {
|
||||
cwd: root,
|
||||
cwd: process.env.HERMES_CWD || root,
|
||||
stdio: ['pipe', 'pipe', 'pipe']
|
||||
})
|
||||
|
||||
|
||||
@@ -19,14 +19,21 @@ const renderEstimateLine = (line: string) => {
|
||||
}
|
||||
|
||||
return line
|
||||
.replace(/!\[(.*?)\]\(([^)\s]+)\)/g, '[image: $1]')
|
||||
.replace(/\[(.+?)\]\((https?:\/\/[^\s)]+)\)/g, '$1')
|
||||
.replace(/`([^`]+)`/g, '$1')
|
||||
.replace(/\*\*(.+?)\*\*/g, '$1')
|
||||
.replace(/__(.+?)__/g, '$1')
|
||||
.replace(/\*(.+?)\*/g, '$1')
|
||||
.replace(/^#{1,3}\s+/, '')
|
||||
.replace(/^\s*[-*]\s+/, '• ')
|
||||
.replace(/_(.+?)_/g, '$1')
|
||||
.replace(/~~(.+?)~~/g, '$1')
|
||||
.replace(/==(.+?)==/g, '$1')
|
||||
.replace(/\[\^([^\]]+)\]/g, '[$1]')
|
||||
.replace(/^#{1,6}\s+/, '')
|
||||
.replace(/^\s*[-*+]\s+\[( |x|X)\]\s+/, (_m, checked: string) => `• [${checked.toLowerCase() === 'x' ? 'x' : ' '}] `)
|
||||
.replace(/^\s*[-*+]\s+/, '• ')
|
||||
.replace(/^\s*(\d+)\.\s+/, '$1. ')
|
||||
.replace(/^>\s?/, '│ ')
|
||||
.replace(/^\s*(?:>\s*)+/, '│ ')
|
||||
}
|
||||
|
||||
export const compactPreview = (s: string, max: number) => {
|
||||
@@ -79,26 +86,34 @@ export const scaleHex = (hex: string, k: number) => {
|
||||
}
|
||||
|
||||
export const estimateRows = (text: string, w: number, compact = false) => {
|
||||
let inCode = false
|
||||
let fence: { char: '`' | '~'; len: number } | null = null
|
||||
let rows = 0
|
||||
|
||||
for (const raw of text.split('\n')) {
|
||||
const line = stripAnsi(raw)
|
||||
const maybeFence = line.match(/^\s*(`{3,}|~{3,})(.*)$/)
|
||||
|
||||
if (line.startsWith('```')) {
|
||||
if (!inCode) {
|
||||
const lang = line.slice(3).trim()
|
||||
if (maybeFence) {
|
||||
const marker = maybeFence[1]!
|
||||
const lang = maybeFence[2]!.trim()
|
||||
|
||||
if (!fence) {
|
||||
fence = {
|
||||
char: marker[0] as '`' | '~',
|
||||
len: marker.length
|
||||
}
|
||||
|
||||
if (lang) {
|
||||
rows += Math.ceil((`─ ${lang}`.length || 1) / w)
|
||||
}
|
||||
} else if (marker[0] === fence.char && marker.length >= fence.len) {
|
||||
fence = null
|
||||
}
|
||||
|
||||
inCode = !inCode
|
||||
|
||||
continue
|
||||
}
|
||||
|
||||
const inCode = Boolean(fence)
|
||||
const trimmed = line.trim()
|
||||
|
||||
if (!inCode && trimmed.startsWith('|') && /^[|\s:-]+$/.test(trimmed)) {
|
||||
|
||||
8
ui-tui/src/types/hermes-ink.d.ts
vendored
8
ui-tui/src/types/hermes-ink.d.ts
vendored
@@ -22,7 +22,13 @@ declare module '@hermes/ink' {
|
||||
readonly [key: string]: boolean
|
||||
}
|
||||
|
||||
export type InputHandler = (input: string, key: Key) => void
|
||||
export type InputEvent = {
|
||||
readonly input: string
|
||||
readonly key: Key
|
||||
readonly keypress: { readonly raw?: string }
|
||||
}
|
||||
|
||||
export type InputHandler = (input: string, key: Key, event: InputEvent) => void
|
||||
|
||||
export type RenderOptions = {
|
||||
readonly stdin?: NodeJS.ReadStream
|
||||
|
||||
Reference in New Issue
Block a user