mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-04-25 00:51:20 +00:00
feat: add tests and update mds
This commit is contained in:
parent
f226e6be10
commit
9d8f9765c1
11 changed files with 6013 additions and 4 deletions
64
AGENTS.md
64
AGENTS.md
|
|
@ -56,6 +56,18 @@ hermes-agent/
|
||||||
│ ├── run.py # Main loop, slash commands, message dispatch
|
│ ├── run.py # Main loop, slash commands, message dispatch
|
||||||
│ ├── session.py # SessionStore — conversation persistence
|
│ ├── session.py # SessionStore — conversation persistence
|
||||||
│ └── platforms/ # Adapters: telegram, discord, slack, whatsapp, homeassistant, signal
|
│ └── platforms/ # Adapters: telegram, discord, slack, whatsapp, homeassistant, signal
|
||||||
|
├── ui-tui/ # Ink (React) terminal UI — `hermes --tui`
|
||||||
|
│ ├── src/entry.tsx # TTY gate + render()
|
||||||
|
│ ├── src/app.tsx # Main state machine and UI
|
||||||
|
│ ├── src/gatewayClient.ts # Child process + JSON-RPC bridge
|
||||||
|
│ ├── src/components/ # Ink components (branding, markdown, prompts, etc.)
|
||||||
|
│ ├── src/hooks/ # useCompletion, useInputHistory, useQueue
|
||||||
|
│ └── src/lib/ # Pure helpers (history, osc52, text)
|
||||||
|
├── tui_gateway/ # Python JSON-RPC backend for Ink TUI
|
||||||
|
│ ├── entry.py # stdio entrypoint
|
||||||
|
│ ├── server.py # RPC handlers and session logic
|
||||||
|
│ ├── render.py # Optional rich/ANSI bridge
|
||||||
|
│ └── slash_worker.py # Persistent HermesCLI subprocess for slash commands
|
||||||
├── acp_adapter/ # ACP server (VS Code / Zed / JetBrains integration)
|
├── acp_adapter/ # ACP server (VS Code / Zed / JetBrains integration)
|
||||||
├── cron/ # Scheduler (jobs.py, scheduler.py)
|
├── cron/ # Scheduler (jobs.py, scheduler.py)
|
||||||
├── environments/ # RL training environments (Atropos)
|
├── environments/ # RL training environments (Atropos)
|
||||||
|
|
@ -179,6 +191,58 @@ if canonical == "mycommand":
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
## TUI Architecture (ui-tui + tui_gateway)
|
||||||
|
|
||||||
|
The Ink TUI is a full replacement for the PT CLI, activated via `hermes --tui` or `HERMES_TUI=1`.
|
||||||
|
|
||||||
|
### Process Model
|
||||||
|
|
||||||
|
```
|
||||||
|
hermes --tui
|
||||||
|
└─ Node (Ink) ──stdio JSON-RPC── Python (tui_gateway)
|
||||||
|
│ └─ AIAgent + tools + sessions
|
||||||
|
└─ renders transcript, composer, prompts, activity
|
||||||
|
```
|
||||||
|
|
||||||
|
TypeScript owns the screen. Python owns sessions, tools, model calls, and slash command logic.
|
||||||
|
|
||||||
|
### Transport
|
||||||
|
|
||||||
|
Newline-delimited JSON-RPC over stdio. Requests from Ink, events from Python. See `tui_gateway/server.py` for the full method/event catalog.
|
||||||
|
|
||||||
|
### Key Surfaces
|
||||||
|
|
||||||
|
| Surface | Ink component | Gateway method |
|
||||||
|
|---------|---------------|----------------|
|
||||||
|
| Chat streaming | `app.tsx` + `messageLine.tsx` | `prompt.submit` → `message.delta/complete` |
|
||||||
|
| Tool activity | `activityLane.tsx` | `tool.start/progress/complete` |
|
||||||
|
| Approvals | `prompts.tsx` | `approval.respond` ← `approval.request` |
|
||||||
|
| Clarify/sudo/secret | `prompts.tsx`, `maskedPrompt.tsx` | `clarify/sudo/secret.respond` |
|
||||||
|
| Session picker | `sessionPicker.tsx` | `session.list/resume` |
|
||||||
|
| Slash commands | Local handler + fallthrough | `slash.exec` → `_SlashWorker`, `command.dispatch` |
|
||||||
|
| Completions | `useCompletion` hook | `complete.slash`, `complete.path` |
|
||||||
|
| Theming | `theme.ts` + `branding.tsx` | `gateway.ready` with skin data |
|
||||||
|
|
||||||
|
### Slash Command Flow
|
||||||
|
|
||||||
|
1. Built-in client commands (`/help`, `/quit`, `/clear`, `/resume`, `/copy`, `/paste`, etc.) handled locally in `app.tsx`
|
||||||
|
2. Everything else → `slash.exec` (runs in persistent `_SlashWorker` subprocess) → `command.dispatch` fallback
|
||||||
|
|
||||||
|
### Dev Commands
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd ui-tui
|
||||||
|
npm install # first time
|
||||||
|
npm run dev # watch mode
|
||||||
|
npm start # production
|
||||||
|
npm run build # typecheck
|
||||||
|
npm run lint # eslint
|
||||||
|
npm run fmt # prettier
|
||||||
|
npm test # vitest
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## Adding New Tools
|
## Adding New Tools
|
||||||
|
|
||||||
Requires changes in **3 files**:
|
Requires changes in **3 files**:
|
||||||
|
|
|
||||||
0
tests/tui_gateway/__init__.py
Normal file
0
tests/tui_gateway/__init__.py
Normal file
187
tests/tui_gateway/test_protocol.py
Normal file
187
tests/tui_gateway/test_protocol.py
Normal file
|
|
@ -0,0 +1,187 @@
|
||||||
|
"""Tests for tui_gateway JSON-RPC protocol plumbing."""
|
||||||
|
|
||||||
|
import io
|
||||||
|
import json
|
||||||
|
import sys
|
||||||
|
import threading
|
||||||
|
from unittest.mock import MagicMock, patch
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
_original_stdout = sys.stdout
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture(autouse=True)
|
||||||
|
def _restore_stdout():
|
||||||
|
yield
|
||||||
|
sys.stdout = _original_stdout
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture()
|
||||||
|
def server():
|
||||||
|
with patch.dict("sys.modules", {
|
||||||
|
"hermes_constants": MagicMock(get_hermes_home=MagicMock(return_value="/tmp/hermes_test")),
|
||||||
|
"hermes_cli.env_loader": MagicMock(),
|
||||||
|
"hermes_cli.banner": MagicMock(),
|
||||||
|
"hermes_state": MagicMock(),
|
||||||
|
}):
|
||||||
|
import importlib
|
||||||
|
mod = importlib.import_module("tui_gateway.server")
|
||||||
|
yield mod
|
||||||
|
mod._sessions.clear()
|
||||||
|
mod._pending.clear()
|
||||||
|
mod._answers.clear()
|
||||||
|
mod._methods.clear()
|
||||||
|
importlib.reload(mod)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture()
|
||||||
|
def capture(server):
|
||||||
|
"""Redirect server's real stdout to a StringIO and return (server, buf)."""
|
||||||
|
buf = io.StringIO()
|
||||||
|
server._real_stdout = buf
|
||||||
|
return server, buf
|
||||||
|
|
||||||
|
|
||||||
|
# ── JSON-RPC envelope ────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
def test_unknown_method(server):
|
||||||
|
resp = server.handle_request({"id": "1", "method": "bogus"})
|
||||||
|
assert resp["error"]["code"] == -32601
|
||||||
|
|
||||||
|
|
||||||
|
def test_ok_envelope(server):
|
||||||
|
assert server._ok("r1", {"x": 1}) == {
|
||||||
|
"jsonrpc": "2.0", "id": "r1", "result": {"x": 1},
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def test_err_envelope(server):
|
||||||
|
assert server._err("r2", 4001, "nope") == {
|
||||||
|
"jsonrpc": "2.0", "id": "r2", "error": {"code": 4001, "message": "nope"},
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
# ── write_json ───────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
def test_write_json(capture):
|
||||||
|
server, buf = capture
|
||||||
|
assert server.write_json({"test": True})
|
||||||
|
assert json.loads(buf.getvalue()) == {"test": True}
|
||||||
|
|
||||||
|
|
||||||
|
def test_write_json_broken_pipe(server):
|
||||||
|
class _Broken:
|
||||||
|
def write(self, _): raise BrokenPipeError
|
||||||
|
def flush(self): raise BrokenPipeError
|
||||||
|
|
||||||
|
server._real_stdout = _Broken()
|
||||||
|
assert server.write_json({"x": 1}) is False
|
||||||
|
|
||||||
|
|
||||||
|
# ── _emit ────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
def test_emit_with_payload(capture):
|
||||||
|
server, buf = capture
|
||||||
|
server._emit("test.event", "s1", {"key": "val"})
|
||||||
|
msg = json.loads(buf.getvalue())
|
||||||
|
|
||||||
|
assert msg["method"] == "event"
|
||||||
|
assert msg["params"]["type"] == "test.event"
|
||||||
|
assert msg["params"]["session_id"] == "s1"
|
||||||
|
assert msg["params"]["payload"]["key"] == "val"
|
||||||
|
|
||||||
|
|
||||||
|
def test_emit_without_payload(capture):
|
||||||
|
server, buf = capture
|
||||||
|
server._emit("ping", "s2")
|
||||||
|
|
||||||
|
assert "payload" not in json.loads(buf.getvalue())["params"]
|
||||||
|
|
||||||
|
|
||||||
|
# ── Blocking prompt round-trip ───────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
def test_block_and_respond(capture):
|
||||||
|
server, _ = capture
|
||||||
|
result = [None]
|
||||||
|
|
||||||
|
threading.Thread(
|
||||||
|
target=lambda: result.__setitem__(0, server._block("test.prompt", "s1", {"q": "?"}, timeout=5)),
|
||||||
|
).start()
|
||||||
|
|
||||||
|
for _ in range(100):
|
||||||
|
if server._pending:
|
||||||
|
break
|
||||||
|
threading.Event().wait(0.01)
|
||||||
|
|
||||||
|
rid = next(iter(server._pending))
|
||||||
|
server._answers[rid] = "my_answer"
|
||||||
|
server._pending[rid].set()
|
||||||
|
|
||||||
|
threading.Event().wait(0.1)
|
||||||
|
assert result[0] == "my_answer"
|
||||||
|
|
||||||
|
|
||||||
|
def test_clear_pending(server):
|
||||||
|
ev = threading.Event()
|
||||||
|
server._pending["r1"] = ev
|
||||||
|
server._clear_pending()
|
||||||
|
|
||||||
|
assert ev.is_set()
|
||||||
|
assert server._answers["r1"] == ""
|
||||||
|
|
||||||
|
|
||||||
|
# ── Session lookup ───────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
def test_sess_missing(server):
|
||||||
|
_, err = server._sess({"session_id": "nope"}, "r1")
|
||||||
|
assert err["error"]["code"] == 4001
|
||||||
|
|
||||||
|
|
||||||
|
def test_sess_found(server):
|
||||||
|
server._sessions["abc"] = {"agent": MagicMock()}
|
||||||
|
s, err = server._sess({"session_id": "abc"}, "r1")
|
||||||
|
|
||||||
|
assert s is not None
|
||||||
|
assert err is None
|
||||||
|
|
||||||
|
|
||||||
|
# ── Config I/O ───────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
def test_config_load_missing(server, tmp_path):
|
||||||
|
server._hermes_home = tmp_path
|
||||||
|
assert server._load_cfg() == {}
|
||||||
|
|
||||||
|
|
||||||
|
def test_config_roundtrip(server, tmp_path):
|
||||||
|
server._hermes_home = tmp_path
|
||||||
|
server._save_cfg({"model": "test/model"})
|
||||||
|
assert server._load_cfg()["model"] == "test/model"
|
||||||
|
|
||||||
|
|
||||||
|
# ── _cli_exec_blocked ────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("argv", [
|
||||||
|
[],
|
||||||
|
["setup"],
|
||||||
|
["gateway"],
|
||||||
|
["sessions", "browse"],
|
||||||
|
["config", "edit"],
|
||||||
|
])
|
||||||
|
def test_cli_exec_blocked(server, argv):
|
||||||
|
assert server._cli_exec_blocked(argv) is not None
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("argv", [
|
||||||
|
["version"],
|
||||||
|
["sessions", "list"],
|
||||||
|
])
|
||||||
|
def test_cli_exec_allowed(server, argv):
|
||||||
|
assert server._cli_exec_blocked(argv) is None
|
||||||
67
tests/tui_gateway/test_render.py
Normal file
67
tests/tui_gateway/test_render.py
Normal file
|
|
@ -0,0 +1,67 @@
|
||||||
|
"""Tests for tui_gateway.render — rendering bridge fallback behavior."""
|
||||||
|
|
||||||
|
from unittest.mock import MagicMock, patch
|
||||||
|
|
||||||
|
from tui_gateway.render import make_stream_renderer, render_diff, render_message
|
||||||
|
|
||||||
|
|
||||||
|
def _stub_rich(mock_mod):
|
||||||
|
return patch.dict("sys.modules", {"agent.rich_output": mock_mod})
|
||||||
|
|
||||||
|
|
||||||
|
def _no_rich():
|
||||||
|
return patch.dict("sys.modules", {"agent.rich_output": None})
|
||||||
|
|
||||||
|
|
||||||
|
# ── render_message ───────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
def test_render_message_none_without_module():
|
||||||
|
with _no_rich():
|
||||||
|
assert render_message("hello") is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_render_message_formatted():
|
||||||
|
mod = MagicMock()
|
||||||
|
mod.format_response.return_value = "<b>hi</b>"
|
||||||
|
|
||||||
|
with _stub_rich(mod):
|
||||||
|
assert render_message("hi", 100) == "<b>hi</b>"
|
||||||
|
|
||||||
|
|
||||||
|
def test_render_message_type_error_fallback():
|
||||||
|
mod = MagicMock()
|
||||||
|
mod.format_response.side_effect = [TypeError, "fallback"]
|
||||||
|
|
||||||
|
with _stub_rich(mod):
|
||||||
|
assert render_message("hi") == "fallback"
|
||||||
|
|
||||||
|
|
||||||
|
def test_render_message_exception_returns_none():
|
||||||
|
mod = MagicMock()
|
||||||
|
mod.format_response.side_effect = RuntimeError
|
||||||
|
|
||||||
|
with _stub_rich(mod):
|
||||||
|
assert render_message("hi") is None
|
||||||
|
|
||||||
|
|
||||||
|
# ── render_diff / make_stream_renderer ───────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
def test_render_diff_none_without_module():
|
||||||
|
with _no_rich():
|
||||||
|
assert render_diff("+line") is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_stream_renderer_none_without_module():
|
||||||
|
with _no_rich():
|
||||||
|
assert make_stream_renderer() is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_stream_renderer_returns_instance():
|
||||||
|
renderer = MagicMock()
|
||||||
|
mod = MagicMock()
|
||||||
|
mod.StreamingRenderer.return_value = renderer
|
||||||
|
|
||||||
|
with _stub_rich(mod):
|
||||||
|
assert make_stream_renderer(120) is renderer
|
||||||
|
|
@ -294,5 +294,4 @@ tui_gateway/
|
||||||
|
|
||||||
## Notes
|
## Notes
|
||||||
|
|
||||||
- `src/main.tsx` currently duplicates `entry.tsx`.
|
- No dead code: `main.tsx`, `altScreen.tsx`, `commandPalette.tsx`, and `lib/slash.ts` have been removed.
|
||||||
- `src/altScreen.tsx`, `components/commandPalette.tsx`, and `lib/slash.ts` exist, but are not part of the active runtime path from `entry.tsx` to `app.tsx`.
|
|
||||||
|
|
|
||||||
5457
ui-tui/package-lock.json
generated
Normal file
5457
ui-tui/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load diff
|
|
@ -10,7 +10,9 @@
|
||||||
"lint": "eslint src/",
|
"lint": "eslint src/",
|
||||||
"lint:fix": "eslint src/ --fix",
|
"lint:fix": "eslint src/ --fix",
|
||||||
"fmt": "prettier --write 'src/**/*.{ts,tsx}'",
|
"fmt": "prettier --write 'src/**/*.{ts,tsx}'",
|
||||||
"fix": "npm run lint:fix && npm run fmt"
|
"fix": "npm run lint:fix && npm run fmt",
|
||||||
|
"test": "vitest run",
|
||||||
|
"test:watch": "vitest"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"ink": "^6.8.0",
|
"ink": "^6.8.0",
|
||||||
|
|
@ -32,6 +34,7 @@
|
||||||
"globals": "^16",
|
"globals": "^16",
|
||||||
"prettier": "^3",
|
"prettier": "^3",
|
||||||
"tsx": "^4.19.0",
|
"tsx": "^4.19.0",
|
||||||
"typescript": "^5.7.0"
|
"typescript": "^5.7.0",
|
||||||
|
"vitest": "^4.1.3"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
43
ui-tui/src/__tests__/constants.test.ts
Normal file
43
ui-tui/src/__tests__/constants.test.ts
Normal file
|
|
@ -0,0 +1,43 @@
|
||||||
|
import { describe, expect, it } from 'vitest'
|
||||||
|
|
||||||
|
import { FACES, HOTKEYS, INTERPOLATION_RE, PLACEHOLDERS, ROLE, TOOL_VERBS, VERBS, ZERO } from '../constants.js'
|
||||||
|
import { DEFAULT_THEME } from '../theme.js'
|
||||||
|
|
||||||
|
|
||||||
|
describe('constants', () => {
|
||||||
|
|
||||||
|
it('ZERO', () => expect(ZERO).toEqual({ calls: 0, input: 0, output: 0, total: 0 }))
|
||||||
|
|
||||||
|
it('string arrays are populated', () => {
|
||||||
|
for (const arr of [FACES, PLACEHOLDERS, VERBS]) {
|
||||||
|
expect(arr.length).toBeGreaterThan(0)
|
||||||
|
arr.forEach(s => expect(typeof s).toBe('string'))
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
it('HOTKEYS are [key, desc] pairs', () => {
|
||||||
|
HOTKEYS.forEach(([k, d]) => {
|
||||||
|
expect(typeof k).toBe('string')
|
||||||
|
expect(typeof d).toBe('string')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
it('TOOL_VERBS maps known tools', () => {
|
||||||
|
expect(TOOL_VERBS.terminal).toContain('terminal')
|
||||||
|
expect(TOOL_VERBS.read_file).toContain('reading')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('INTERPOLATION_RE matches {!cmd}', () => {
|
||||||
|
INTERPOLATION_RE.lastIndex = 0
|
||||||
|
expect(INTERPOLATION_RE.test('{!date}')).toBe(true)
|
||||||
|
|
||||||
|
INTERPOLATION_RE.lastIndex = 0
|
||||||
|
expect(INTERPOLATION_RE.test('plain')).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('ROLE produces glyph/body/prefix per role', () => {
|
||||||
|
for (const role of ['assistant', 'system', 'tool', 'user'] as const) {
|
||||||
|
expect(ROLE[role](DEFAULT_THEME)).toHaveProperty('glyph')
|
||||||
|
}
|
||||||
|
})
|
||||||
|
})
|
||||||
25
ui-tui/src/__tests__/messages.test.ts
Normal file
25
ui-tui/src/__tests__/messages.test.ts
Normal file
|
|
@ -0,0 +1,25 @@
|
||||||
|
import { describe, expect, it } from 'vitest'
|
||||||
|
|
||||||
|
import { upsert } from '../lib/messages.js'
|
||||||
|
|
||||||
|
|
||||||
|
describe('upsert', () => {
|
||||||
|
|
||||||
|
it('appends when last role differs', () => {
|
||||||
|
expect(upsert([{ role: 'user', text: 'hi' }], 'assistant', 'hello')).toHaveLength(2)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('replaces when last role matches', () => {
|
||||||
|
expect(upsert([{ role: 'assistant', text: 'partial' }], 'assistant', 'full')[0]!.text).toBe('full')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('appends to empty', () => {
|
||||||
|
expect(upsert([], 'user', 'first')).toEqual([{ role: 'user', text: 'first' }])
|
||||||
|
})
|
||||||
|
|
||||||
|
it('does not mutate', () => {
|
||||||
|
const prev = [{ role: 'user' as const, text: 'hi' }]
|
||||||
|
upsert(prev, 'assistant', 'yo')
|
||||||
|
expect(prev).toHaveLength(1)
|
||||||
|
})
|
||||||
|
})
|
||||||
112
ui-tui/src/__tests__/text.test.ts
Normal file
112
ui-tui/src/__tests__/text.test.ts
Normal file
|
|
@ -0,0 +1,112 @@
|
||||||
|
import { describe, expect, it } from 'vitest'
|
||||||
|
|
||||||
|
import {
|
||||||
|
compactPreview,
|
||||||
|
estimateRows,
|
||||||
|
fmtK,
|
||||||
|
hasAnsi,
|
||||||
|
hasInterpolation,
|
||||||
|
pick,
|
||||||
|
stripAnsi,
|
||||||
|
userDisplay
|
||||||
|
} from '../lib/text.js'
|
||||||
|
|
||||||
|
|
||||||
|
describe('stripAnsi / hasAnsi', () => {
|
||||||
|
|
||||||
|
it('strips ANSI codes', () => {
|
||||||
|
expect(stripAnsi('\x1b[31mred\x1b[0m')).toBe('red')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('passes plain text through', () => {
|
||||||
|
expect(stripAnsi('hello')).toBe('hello')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('detects ANSI', () => {
|
||||||
|
expect(hasAnsi('\x1b[1mbold\x1b[0m')).toBe(true)
|
||||||
|
expect(hasAnsi('plain')).toBe(false)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
|
||||||
|
describe('compactPreview', () => {
|
||||||
|
|
||||||
|
it('truncates with ellipsis', () => {
|
||||||
|
expect(compactPreview('a'.repeat(100), 20)).toHaveLength(20)
|
||||||
|
expect(compactPreview('a'.repeat(100), 20).at(-1)).toBe('…')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('returns short strings as-is', () => {
|
||||||
|
expect(compactPreview('hello', 20)).toBe('hello')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('collapses whitespace', () => {
|
||||||
|
expect(compactPreview(' a b ', 20)).toBe('a b')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('returns empty for whitespace-only', () => {
|
||||||
|
expect(compactPreview(' ', 20)).toBe('')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
|
||||||
|
describe('estimateRows', () => {
|
||||||
|
|
||||||
|
it('single line', () => expect(estimateRows('hello', 80)).toBe(1))
|
||||||
|
|
||||||
|
it('wraps long lines', () => expect(estimateRows('a'.repeat(160), 80)).toBe(2))
|
||||||
|
|
||||||
|
it('counts newlines', () => expect(estimateRows('a\nb\nc', 80)).toBe(3))
|
||||||
|
|
||||||
|
it('skips table separators', () => {
|
||||||
|
expect(estimateRows('| a | b |\n|---|---|\n| 1 | 2 |', 80)).toBe(2)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('handles code blocks', () => {
|
||||||
|
expect(estimateRows('```python\nprint("hi")\n```', 80)).toBeGreaterThanOrEqual(2)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('compact mode skips empty lines', () => {
|
||||||
|
expect(estimateRows('a\n\nb', 80, true)).toBe(2)
|
||||||
|
expect(estimateRows('a\n\nb', 80, false)).toBe(3)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
|
||||||
|
describe('fmtK', () => {
|
||||||
|
|
||||||
|
it('formats thousands', () => expect(fmtK(1500)).toBe('1.5k'))
|
||||||
|
|
||||||
|
it('keeps small numbers', () => expect(fmtK(42)).toBe('42'))
|
||||||
|
|
||||||
|
it('boundary', () => {
|
||||||
|
expect(fmtK(1000)).toBe('1.0k')
|
||||||
|
expect(fmtK(999)).toBe('999')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
|
||||||
|
describe('hasInterpolation', () => {
|
||||||
|
|
||||||
|
it('detects {!cmd}', () => expect(hasInterpolation('echo {!date}')).toBe(true))
|
||||||
|
|
||||||
|
it('rejects plain text', () => expect(hasInterpolation('plain')).toBe(false))
|
||||||
|
})
|
||||||
|
|
||||||
|
|
||||||
|
describe('pick', () => {
|
||||||
|
|
||||||
|
it('returns element from array', () => {
|
||||||
|
expect([1, 2, 3]).toContain(pick([1, 2, 3]))
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
|
||||||
|
describe('userDisplay', () => {
|
||||||
|
|
||||||
|
it('returns short messages as-is', () => expect(userDisplay('hello')).toBe('hello'))
|
||||||
|
|
||||||
|
it('truncates long messages', () => {
|
||||||
|
expect(userDisplay('word '.repeat(100))).toContain('[long message]')
|
||||||
|
})
|
||||||
|
})
|
||||||
52
ui-tui/src/__tests__/theme.test.ts
Normal file
52
ui-tui/src/__tests__/theme.test.ts
Normal file
|
|
@ -0,0 +1,52 @@
|
||||||
|
import { describe, expect, it } from 'vitest'
|
||||||
|
|
||||||
|
import { DEFAULT_THEME, fromSkin } from '../theme.js'
|
||||||
|
|
||||||
|
|
||||||
|
describe('DEFAULT_THEME', () => {
|
||||||
|
|
||||||
|
it('has brand defaults', () => {
|
||||||
|
expect(DEFAULT_THEME.brand.name).toBe('Hermes Agent')
|
||||||
|
expect(DEFAULT_THEME.brand.prompt).toBe('❯')
|
||||||
|
expect(DEFAULT_THEME.brand.tool).toBe('┊')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('has color palette', () => {
|
||||||
|
expect(DEFAULT_THEME.color.gold).toBe('#FFD700')
|
||||||
|
expect(DEFAULT_THEME.color.error).toBe('#ef5350')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
|
||||||
|
describe('fromSkin', () => {
|
||||||
|
|
||||||
|
it('overrides banner colors', () => {
|
||||||
|
expect(fromSkin({ banner_title: '#FF0000' }, {}).color.gold).toBe('#FF0000')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('preserves unset colors', () => {
|
||||||
|
expect(fromSkin({ banner_title: '#FF0000' }, {}).color.amber).toBe(DEFAULT_THEME.color.amber)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('overrides branding', () => {
|
||||||
|
const { brand } = fromSkin({}, { agent_name: 'TestBot', prompt_symbol: '$' })
|
||||||
|
expect(brand.name).toBe('TestBot')
|
||||||
|
expect(brand.prompt).toBe('$')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('defaults for empty skin', () => {
|
||||||
|
expect(fromSkin({}, {}).color).toEqual(DEFAULT_THEME.color)
|
||||||
|
expect(fromSkin({}, {}).brand.icon).toBe(DEFAULT_THEME.brand.icon)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('passes banner logo/hero', () => {
|
||||||
|
expect(fromSkin({}, {}, 'LOGO', 'HERO').bannerLogo).toBe('LOGO')
|
||||||
|
expect(fromSkin({}, {}, 'LOGO', 'HERO').bannerHero).toBe('HERO')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('maps ui_ color keys + cascades to status', () => {
|
||||||
|
const { color } = fromSkin({ ui_ok: '#008000' }, {})
|
||||||
|
expect(color.ok).toBe('#008000')
|
||||||
|
expect(color.statusGood).toBe('#008000')
|
||||||
|
})
|
||||||
|
})
|
||||||
Loading…
Add table
Add a link
Reference in a new issue