mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-04-25 00:51:20 +00:00
refactor(tui): /clean pass across ui-tui — 49 files, −217 LOC
Full codebase pass using the /clean doctrine (KISS/DRY, no one-off
helpers, no variables-used-once, pure functional where natural,
inlined obvious one-liners, killed dead exports, narrowed types,
spaced JSX). All contracts preserved — no RPC method, event name,
or exported type shape changed.
app/ — 15 files, -134 LOC
- inlined 4 one-off helpers (titleCase, isLong, statusToneFrom,
focusOutside predicate)
- stores to arrow-const style (buildUiState, buildTurnState,
buildOverlayState plus get/patch/reset triplets)
- functional slash/registry byName map (flatMap over for-loops)
- dropped dead param `live` in cancelOverlayFromCtrlC
- DRY'd duplicate shift() call in scrollWithSelection
- consolidated sections.push calls in /help
components/ — 12 files, -40 LOC
- extracted inline prop types to interfaces at file bottom (13×)
- inlined 6 one-off vars (pctLabel, logoW, heroW, cwd, title, hint)
- promoted HEART_COLORS + OPTS/LABELS to module scope
- JSX sibling spacing across 9 files
- un-shadowed `raw` in textInput
- components/thinking.tsx + components/markdown.tsx untouched
(structurally load-bearing / edge-case-heavy)
config content domain protocol/ — 8 files, -77 LOC
- tightened 3 regexes (MOUSE_TRACKING, looksLikeSlashCommand,
hasInterpolation — dropped stateful lastIndex dance)
- dead export ParsedSlashCommand removed
- MODES narrowed to `as const`, `.find(m => m === s)` replaces
`.includes() ? (as cast) : null`
- fortunes.ts hash via reduce
- fmtDuration ternary chain
- inlined aboveViewport predicate in viewport.ts
hooks/ + lib/ — 9 files, -38 LOC
- ANSI_RE via String.fromCharCode(27) + WS_RE lifted to module
scope (no more eslint-disable no-control-regex)
- compactPreview/edgePreview/thinkingPreview → ternary arrows
- useCompletion: hoisted pathReplace, moved stale-ref guard earlier
- useInputHistory: dropped useCallback wrapper (append is stable)
- useVirtualHistory: replaced 4× any with unknown + narrow
MeasuredNode interface + one cast site
root TS — 3 files, -63 LOC
- banner.ts: parseRichMarkup via matchAll instead of exec/lastIndex,
artWidth via reduce
- gatewayClient.ts: resolvePython candidate list collapse, inlined
one-branch guards in dispatch/pushLog/drain/request
- types.ts: alpha-sorted ActiveTool / Msg / SudoReq / SecretReq
members
eslint config
- disabled react-hooks/exhaustive-deps on packages/hermes-ink/**
(compiled by react/compiler, deps live in $[N] memo arrays that
eslint can't introspect) and removed the now-orphan in-file
disable directive in ScrollBox.tsx
fixes (not from the cleaner pass)
- useComposerState: unlinkSync(file) + try/catch → rmSync(file,
{ force: true }) — kills the no-empty lint error and is more
idiomatic
- useConfigSync: added setBellOnComplete + setVoiceEnabled to the
two useEffect dep arrays (they're stable React setState setters;
adding is safe and silences exhaustive-deps)
verification
- npx eslint src/ packages/ → 0 errors, 0 warnings
- npm run type-check → clean
- npm test → 50/50
- npm run build → 394.8kb ink-bundle.js, 11ms esbuild
- pytest tests/tui_gateway/ tests/test_tui_gateway_server.py
tests/hermes_cli/test_tui_resume_flow.py
tests/hermes_cli/test_tui_npm_install.py → 57/57
This commit is contained in:
parent
c730ab8ad7
commit
39231f29c6
49 changed files with 527 additions and 744 deletions
|
|
@ -88,7 +88,8 @@ export default [
|
||||||
'@typescript-eslint/consistent-type-imports': 'off',
|
'@typescript-eslint/consistent-type-imports': 'off',
|
||||||
'no-constant-condition': 'off',
|
'no-constant-condition': 'off',
|
||||||
'no-empty': 'off',
|
'no-empty': 'off',
|
||||||
'no-redeclare': 'off'
|
'no-redeclare': 'off',
|
||||||
|
'react-hooks/exhaustive-deps': 'off'
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|
|
||||||
|
|
@ -235,7 +235,7 @@ function ScrollBox({ children, ref, stickyScroll, ...style }: PropsWithChildren<
|
||||||
// notify/scrollMutated are inline (no useCallback) but only close over
|
// notify/scrollMutated are inline (no useCallback) but only close over
|
||||||
// refs + imports — stable. Empty deps avoids rebuilding the handle on
|
// refs + imports — stable. Empty deps avoids rebuilding the handle on
|
||||||
// every render (which re-registers the ref = churn).
|
// every render (which re-registers the ref = churn).
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
||||||
[]
|
[]
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -35,9 +35,6 @@ const dropBgTask = (taskId: string) =>
|
||||||
return { ...state, bgTasks: next }
|
return { ...state, bgTasks: next }
|
||||||
})
|
})
|
||||||
|
|
||||||
const statusToneFrom = (kind: string): 'error' | 'info' | 'warn' =>
|
|
||||||
kind === 'error' ? 'error' : kind === 'warn' || kind === 'approval' ? 'warn' : 'info'
|
|
||||||
|
|
||||||
const pushUnique =
|
const pushUnique =
|
||||||
(max: number) =>
|
(max: number) =>
|
||||||
<T>(xs: T[], x: T): T[] =>
|
<T>(xs: T[], x: T): T[] =>
|
||||||
|
|
@ -213,7 +210,10 @@ export function createGatewayEventHandler(ctx: GatewayEventHandlerContext): (ev:
|
||||||
|
|
||||||
if (turnController.lastStatusNote !== p.text) {
|
if (turnController.lastStatusNote !== p.text) {
|
||||||
turnController.lastStatusNote = p.text
|
turnController.lastStatusNote = p.text
|
||||||
turnController.pushActivity(p.text, statusToneFrom(p.kind))
|
turnController.pushActivity(
|
||||||
|
p.text,
|
||||||
|
p.kind === 'error' ? 'error' : p.kind === 'warn' || p.kind === 'approval' ? 'warn' : 'info'
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
restoreStatusAfter(4000)
|
restoreStatusAfter(4000)
|
||||||
|
|
|
||||||
|
|
@ -7,10 +7,6 @@ import { findSlashCommand } from './slash/registry.js'
|
||||||
import type { SlashRunCtx } from './slash/types.js'
|
import type { SlashRunCtx } from './slash/types.js'
|
||||||
import { getUiState } from './uiStore.js'
|
import { getUiState } from './uiStore.js'
|
||||||
|
|
||||||
const titleCase = (name: string) => name.charAt(0).toUpperCase() + name.slice(1)
|
|
||||||
|
|
||||||
const isLong = (text: string) => text.length > 180 || text.split('\n').filter(Boolean).length > 2
|
|
||||||
|
|
||||||
export function createSlashHandler(ctx: SlashHandlerContext): (cmd: string) => boolean {
|
export function createSlashHandler(ctx: SlashHandlerContext): (cmd: string) => boolean {
|
||||||
const { gw } = ctx.gateway
|
const { gw } = ctx.gateway
|
||||||
const { catalog } = ctx.local
|
const { catalog } = ctx.local
|
||||||
|
|
@ -79,8 +75,9 @@ export function createSlashHandler(ctx: SlashHandlerContext): (cmd: string) => b
|
||||||
|
|
||||||
const body = r?.output || `/${parsed.name}: no output`
|
const body = r?.output || `/${parsed.name}: no output`
|
||||||
const text = r?.warning ? `warning: ${r.warning}\n${body}` : body
|
const text = r?.warning ? `warning: ${r.warning}\n${body}` : body
|
||||||
|
const long = text.length > 180 || text.split('\n').filter(Boolean).length > 2
|
||||||
|
|
||||||
isLong(text) ? page(text, titleCase(parsed.name)) : sys(text)
|
long ? page(text, parsed.name[0]!.toUpperCase() + parsed.name.slice(1)) : sys(text)
|
||||||
})
|
})
|
||||||
.catch(() => {
|
.catch(() => {
|
||||||
gw.request('command.dispatch', { arg: parsed.arg, name: parsed.name, session_id: sid })
|
gw.request('command.dispatch', { arg: parsed.arg, name: parsed.name, session_id: sid })
|
||||||
|
|
|
||||||
|
|
@ -2,8 +2,7 @@ import { atom, computed } from 'nanostores'
|
||||||
|
|
||||||
import type { OverlayState } from './interfaces.js'
|
import type { OverlayState } from './interfaces.js'
|
||||||
|
|
||||||
function buildOverlayState(): OverlayState {
|
const buildOverlayState = (): OverlayState => ({
|
||||||
return {
|
|
||||||
approval: null,
|
approval: null,
|
||||||
clarify: null,
|
clarify: null,
|
||||||
modelPicker: false,
|
modelPicker: false,
|
||||||
|
|
@ -11,31 +10,17 @@ function buildOverlayState(): OverlayState {
|
||||||
picker: false,
|
picker: false,
|
||||||
secret: null,
|
secret: null,
|
||||||
sudo: null
|
sudo: null
|
||||||
}
|
})
|
||||||
}
|
|
||||||
|
|
||||||
export const $overlayState = atom<OverlayState>(buildOverlayState())
|
export const $overlayState = atom<OverlayState>(buildOverlayState())
|
||||||
|
|
||||||
export const $isBlocked = computed($overlayState, state =>
|
export const $isBlocked = computed($overlayState, ({ approval, clarify, modelPicker, pager, picker, secret, sudo }) =>
|
||||||
Boolean(
|
Boolean(approval || clarify || modelPicker || pager || picker || secret || sudo)
|
||||||
state.approval || state.clarify || state.modelPicker || state.pager || state.picker || state.secret || state.sudo
|
|
||||||
)
|
|
||||||
)
|
)
|
||||||
|
|
||||||
export function getOverlayState() {
|
export const getOverlayState = () => $overlayState.get()
|
||||||
return $overlayState.get()
|
|
||||||
}
|
|
||||||
|
|
||||||
export function patchOverlayState(next: Partial<OverlayState> | ((state: OverlayState) => OverlayState)) {
|
export const patchOverlayState = (next: Partial<OverlayState> | ((state: OverlayState) => OverlayState)) =>
|
||||||
if (typeof next === 'function') {
|
$overlayState.set(typeof next === 'function' ? next($overlayState.get()) : { ...$overlayState.get(), ...next })
|
||||||
$overlayState.set(next($overlayState.get()))
|
|
||||||
|
|
||||||
return
|
export const resetOverlayState = () => $overlayState.set(buildOverlayState())
|
||||||
}
|
|
||||||
|
|
||||||
$overlayState.set({ ...$overlayState.get(), ...next })
|
|
||||||
}
|
|
||||||
|
|
||||||
export function resetOverlayState() {
|
|
||||||
$overlayState.set(buildOverlayState())
|
|
||||||
}
|
|
||||||
|
|
|
||||||
|
|
@ -9,12 +9,12 @@ import { patchUiState } from '../../uiStore.js'
|
||||||
import type { SlashCommand } from '../types.js'
|
import type { SlashCommand } from '../types.js'
|
||||||
|
|
||||||
const flagFromArg = (arg: string, current: boolean): boolean | null => {
|
const flagFromArg = (arg: string, current: boolean): boolean | null => {
|
||||||
const mode = arg.trim().toLowerCase()
|
|
||||||
|
|
||||||
if (!arg) {
|
if (!arg) {
|
||||||
return !current
|
return !current
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const mode = arg.trim().toLowerCase()
|
||||||
|
|
||||||
if (mode === 'on') {
|
if (mode === 'on') {
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|
@ -46,14 +46,16 @@ export const coreCommands: SlashCommand[] = [
|
||||||
sections.push({ text: `${ctx.local.catalog.skillCount} skill commands available — /skills to browse` })
|
sections.push({ text: `${ctx.local.catalog.skillCount} skill commands available — /skills to browse` })
|
||||||
}
|
}
|
||||||
|
|
||||||
sections.push({
|
sections.push(
|
||||||
|
{
|
||||||
rows: [
|
rows: [
|
||||||
['/details [hidden|collapsed|expanded|cycle]', 'set agent detail visibility mode'],
|
['/details [hidden|collapsed|expanded|cycle]', 'set agent detail visibility mode'],
|
||||||
['/fortune [random|daily]', 'show a random or daily local fortune']
|
['/fortune [random|daily]', 'show a random or daily local fortune']
|
||||||
],
|
],
|
||||||
title: 'TUI'
|
title: 'TUI'
|
||||||
})
|
},
|
||||||
sections.push({ rows: HOTKEYS, title: 'Hotkeys' })
|
{ rows: HOTKEYS, title: 'Hotkeys' }
|
||||||
|
)
|
||||||
|
|
||||||
ctx.transcript.panel(ctx.ui.theme.brand.helpHeader, sections)
|
ctx.transcript.panel(ctx.ui.theme.brand.helpHeader, sections)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -9,7 +9,7 @@ export const opsCommands: SlashCommand[] = [
|
||||||
const [subcommand, ...names] = arg.trim().split(/\s+/).filter(Boolean)
|
const [subcommand, ...names] = arg.trim().split(/\s+/).filter(Boolean)
|
||||||
|
|
||||||
if (subcommand !== 'disable' && subcommand !== 'enable') {
|
if (subcommand !== 'disable' && subcommand !== 'enable') {
|
||||||
return // py prints lists / show / usage
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!names.length) {
|
if (!names.length) {
|
||||||
|
|
|
||||||
|
|
@ -109,7 +109,7 @@ export const sessionCommands: SlashCommand[] = [
|
||||||
name: 'personality',
|
name: 'personality',
|
||||||
run: (arg, ctx) => {
|
run: (arg, ctx) => {
|
||||||
if (!arg) {
|
if (!arg) {
|
||||||
return // py handles listing
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
ctx.gateway.rpc<ConfigSetResponse>('config.set', { key: 'personality', session_id: ctx.sid, value: arg }).then(
|
ctx.gateway.rpc<ConfigSetResponse>('config.set', { key: 'personality', session_id: ctx.sid, value: arg }).then(
|
||||||
|
|
@ -200,11 +200,6 @@ export const sessionCommands: SlashCommand[] = [
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
// The four shims below call `config.set` directly because Python's `slash.exec`
|
|
||||||
// worker is a separate subprocess — it writes config but does NOT fire the
|
|
||||||
// live side-effects (`skin.changed` event, agent.reasoning_config,
|
|
||||||
// agent.verbose_logging, per-session yolo flip). Direct RPC does.
|
|
||||||
|
|
||||||
{
|
{
|
||||||
help: 'switch theme skin (fires skin.changed)',
|
help: 'switch theme skin (fires skin.changed)',
|
||||||
name: 'skin',
|
name: 'skin',
|
||||||
|
|
|
||||||
|
|
@ -5,14 +5,8 @@ import type { SlashCommand } from './types.js'
|
||||||
|
|
||||||
export const SLASH_COMMANDS: SlashCommand[] = [...coreCommands, ...sessionCommands, ...opsCommands]
|
export const SLASH_COMMANDS: SlashCommand[] = [...coreCommands, ...sessionCommands, ...opsCommands]
|
||||||
|
|
||||||
const byName = new Map<string, SlashCommand>()
|
const byName = new Map<string, SlashCommand>(
|
||||||
|
SLASH_COMMANDS.flatMap(cmd => [cmd.name, ...(cmd.aliases ?? [])].map(name => [name, cmd] as const))
|
||||||
|
)
|
||||||
|
|
||||||
for (const cmd of SLASH_COMMANDS) {
|
export const findSlashCommand = (name: string) => byName.get(name.toLowerCase())
|
||||||
byName.set(cmd.name, cmd)
|
|
||||||
|
|
||||||
for (const alias of cmd.aliases ?? []) {
|
|
||||||
byName.set(alias, cmd)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export const findSlashCommand = (name: string): SlashCommand | undefined => byName.get(name.toLowerCase())
|
|
||||||
|
|
|
||||||
|
|
@ -2,6 +2,28 @@ import { atom } from 'nanostores'
|
||||||
|
|
||||||
import type { ActiveTool, ActivityItem, SubagentProgress } from '../types.js'
|
import type { ActiveTool, ActivityItem, SubagentProgress } from '../types.js'
|
||||||
|
|
||||||
|
const buildTurnState = (): TurnState => ({
|
||||||
|
activity: [],
|
||||||
|
reasoning: '',
|
||||||
|
reasoningActive: false,
|
||||||
|
reasoningStreaming: false,
|
||||||
|
reasoningTokens: 0,
|
||||||
|
streaming: '',
|
||||||
|
subagents: [],
|
||||||
|
toolTokens: 0,
|
||||||
|
tools: [],
|
||||||
|
turnTrail: []
|
||||||
|
})
|
||||||
|
|
||||||
|
export const $turnState = atom<TurnState>(buildTurnState())
|
||||||
|
|
||||||
|
export const getTurnState = () => $turnState.get()
|
||||||
|
|
||||||
|
export const patchTurnState = (next: Partial<TurnState> | ((state: TurnState) => TurnState)) =>
|
||||||
|
$turnState.set(typeof next === 'function' ? next($turnState.get()) : { ...$turnState.get(), ...next })
|
||||||
|
|
||||||
|
export const resetTurnState = () => $turnState.set(buildTurnState())
|
||||||
|
|
||||||
export interface TurnState {
|
export interface TurnState {
|
||||||
activity: ActivityItem[]
|
activity: ActivityItem[]
|
||||||
reasoning: string
|
reasoning: string
|
||||||
|
|
@ -14,34 +36,3 @@ export interface TurnState {
|
||||||
tools: ActiveTool[]
|
tools: ActiveTool[]
|
||||||
turnTrail: string[]
|
turnTrail: string[]
|
||||||
}
|
}
|
||||||
|
|
||||||
function buildTurnState(): TurnState {
|
|
||||||
return {
|
|
||||||
activity: [],
|
|
||||||
reasoning: '',
|
|
||||||
reasoningActive: false,
|
|
||||||
reasoningStreaming: false,
|
|
||||||
reasoningTokens: 0,
|
|
||||||
streaming: '',
|
|
||||||
subagents: [],
|
|
||||||
toolTokens: 0,
|
|
||||||
tools: [],
|
|
||||||
turnTrail: []
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export const $turnState = atom<TurnState>(buildTurnState())
|
|
||||||
|
|
||||||
export const getTurnState = () => $turnState.get()
|
|
||||||
|
|
||||||
export const patchTurnState = (next: Partial<TurnState> | ((state: TurnState) => TurnState)) => {
|
|
||||||
if (typeof next === 'function') {
|
|
||||||
$turnState.set(next($turnState.get()))
|
|
||||||
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
$turnState.set({ ...$turnState.get(), ...next })
|
|
||||||
}
|
|
||||||
|
|
||||||
export const resetTurnState = () => $turnState.set(buildTurnState())
|
|
||||||
|
|
|
||||||
|
|
@ -5,8 +5,7 @@ import { DEFAULT_THEME } from '../theme.js'
|
||||||
|
|
||||||
import type { UiState } from './interfaces.js'
|
import type { UiState } from './interfaces.js'
|
||||||
|
|
||||||
function buildUiState(): UiState {
|
const buildUiState = (): UiState => ({
|
||||||
return {
|
|
||||||
bgTasks: new Set(),
|
bgTasks: new Set(),
|
||||||
busy: false,
|
busy: false,
|
||||||
compact: false,
|
compact: false,
|
||||||
|
|
@ -17,25 +16,13 @@ function buildUiState(): UiState {
|
||||||
statusBar: true,
|
statusBar: true,
|
||||||
theme: DEFAULT_THEME,
|
theme: DEFAULT_THEME,
|
||||||
usage: ZERO
|
usage: ZERO
|
||||||
}
|
})
|
||||||
}
|
|
||||||
|
|
||||||
export const $uiState = atom<UiState>(buildUiState())
|
export const $uiState = atom<UiState>(buildUiState())
|
||||||
|
|
||||||
export function getUiState() {
|
export const getUiState = () => $uiState.get()
|
||||||
return $uiState.get()
|
|
||||||
}
|
|
||||||
|
|
||||||
export function patchUiState(next: Partial<UiState> | ((state: UiState) => UiState)) {
|
export const patchUiState = (next: Partial<UiState> | ((state: UiState) => UiState)) =>
|
||||||
if (typeof next === 'function') {
|
$uiState.set(typeof next === 'function' ? next($uiState.get()) : { ...$uiState.get(), ...next })
|
||||||
$uiState.set(next($uiState.get()))
|
|
||||||
|
|
||||||
return
|
export const resetUiState = () => $uiState.set(buildUiState())
|
||||||
}
|
|
||||||
|
|
||||||
$uiState.set({ ...$uiState.get(), ...next })
|
|
||||||
}
|
|
||||||
|
|
||||||
export function resetUiState() {
|
|
||||||
$uiState.set(buildUiState())
|
|
||||||
}
|
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
import { spawnSync } from 'node:child_process'
|
import { spawnSync } from 'node:child_process'
|
||||||
import { mkdtempSync, readFileSync, unlinkSync, writeFileSync } from 'node:fs'
|
import { mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'
|
||||||
import { tmpdir } from 'node:os'
|
import { tmpdir } from 'node:os'
|
||||||
import { join } from 'node:path'
|
import { join } from 'node:path'
|
||||||
|
|
||||||
|
|
@ -97,11 +97,7 @@ export function useComposerState({ gw, onClipboardPaste, submitRef }: UseCompose
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
rmSync(file, { force: true })
|
||||||
unlinkSync(file)
|
|
||||||
} catch {
|
|
||||||
/* noop */
|
|
||||||
}
|
|
||||||
}, [input, inputBuf, submitRef])
|
}, [input, inputBuf, submitRef])
|
||||||
|
|
||||||
const actions = useMemo(
|
const actions = useMemo(
|
||||||
|
|
|
||||||
|
|
@ -15,23 +15,16 @@ import { patchUiState } from './uiStore.js'
|
||||||
const MTIME_POLL_MS = 5000
|
const MTIME_POLL_MS = 5000
|
||||||
|
|
||||||
const applyDisplay = (cfg: ConfigFullResponse | null, setBell: (v: boolean) => void) => {
|
const applyDisplay = (cfg: ConfigFullResponse | null, setBell: (v: boolean) => void) => {
|
||||||
const display = cfg?.config?.display ?? {}
|
const d = cfg?.config?.display ?? {}
|
||||||
|
|
||||||
setBell(!!display.bell_on_complete)
|
setBell(!!d.bell_on_complete)
|
||||||
patchUiState({
|
patchUiState({
|
||||||
compact: !!display.tui_compact,
|
compact: !!d.tui_compact,
|
||||||
detailsMode: resolveDetailsMode(display),
|
detailsMode: resolveDetailsMode(d),
|
||||||
statusBar: display.tui_statusbar !== false
|
statusBar: d.tui_statusbar !== false
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface UseConfigSyncOptions {
|
|
||||||
rpc: GatewayRpc
|
|
||||||
setBellOnComplete: (v: boolean) => void
|
|
||||||
setVoiceEnabled: (v: boolean) => void
|
|
||||||
sid: null | string
|
|
||||||
}
|
|
||||||
|
|
||||||
export function useConfigSync({ rpc, setBellOnComplete, setVoiceEnabled, sid }: UseConfigSyncOptions) {
|
export function useConfigSync({ rpc, setBellOnComplete, setVoiceEnabled, sid }: UseConfigSyncOptions) {
|
||||||
const mtimeRef = useRef(0)
|
const mtimeRef = useRef(0)
|
||||||
|
|
||||||
|
|
@ -45,8 +38,7 @@ export function useConfigSync({ rpc, setBellOnComplete, setVoiceEnabled, sid }:
|
||||||
mtimeRef.current = Number(r?.mtime ?? 0)
|
mtimeRef.current = Number(r?.mtime ?? 0)
|
||||||
})
|
})
|
||||||
rpc<ConfigFullResponse>('config.get', { key: 'full' }).then(r => applyDisplay(r, setBellOnComplete))
|
rpc<ConfigFullResponse>('config.get', { key: 'full' }).then(r => applyDisplay(r, setBellOnComplete))
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
}, [rpc, setBellOnComplete, setVoiceEnabled, sid])
|
||||||
}, [rpc, sid])
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!sid) {
|
if (!sid) {
|
||||||
|
|
@ -79,6 +71,12 @@ export function useConfigSync({ rpc, setBellOnComplete, setVoiceEnabled, sid }:
|
||||||
}, MTIME_POLL_MS)
|
}, MTIME_POLL_MS)
|
||||||
|
|
||||||
return () => clearInterval(id)
|
return () => clearInterval(id)
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
}, [rpc, setBellOnComplete, sid])
|
||||||
}, [rpc, sid])
|
}
|
||||||
|
|
||||||
|
export interface UseConfigSyncOptions {
|
||||||
|
rpc: GatewayRpc
|
||||||
|
setBellOnComplete: (v: boolean) => void
|
||||||
|
setVoiceEnabled: (v: boolean) => void
|
||||||
|
sid: null | string
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -29,14 +29,14 @@ export function useInputHandlers(ctx: InputHandlerContext): InputHandlerResult {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const cancelOverlayFromCtrlC = (live: ReturnType<typeof getUiState>) => {
|
const cancelOverlayFromCtrlC = () => {
|
||||||
if (overlay.clarify) {
|
if (overlay.clarify) {
|
||||||
return actions.answerClarify('')
|
return actions.answerClarify('')
|
||||||
}
|
}
|
||||||
|
|
||||||
if (overlay.approval) {
|
if (overlay.approval) {
|
||||||
return gateway
|
return gateway
|
||||||
.rpc<ApprovalRespondResponse>('approval.respond', { choice: 'deny', session_id: live.sid })
|
.rpc<ApprovalRespondResponse>('approval.respond', { choice: 'deny', session_id: getUiState().sid })
|
||||||
.then(r => r && (patchOverlayState({ approval: null }), actions.sys('denied')))
|
.then(r => r && (patchOverlayState({ approval: null }), actions.sys('denied')))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -172,7 +172,7 @@ export function useInputHandlers(ctx: InputHandlerContext): InputHandlerResult {
|
||||||
}
|
}
|
||||||
|
|
||||||
if (isCtrl(key, ch, 'c')) {
|
if (isCtrl(key, ch, 'c')) {
|
||||||
cancelOverlayFromCtrlC(live)
|
cancelOverlayFromCtrlC()
|
||||||
} else if (key.escape && overlay.picker) {
|
} else if (key.escape && overlay.picker) {
|
||||||
patchOverlayState({ picker: false })
|
patchOverlayState({ picker: false })
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -47,10 +47,9 @@ export function useLongRunToolCharms(busy: boolean, tools: ActiveTool[]) {
|
||||||
}
|
}
|
||||||
|
|
||||||
slots.current.set(tool.id, { count: slot.count + 1, lastAt: now })
|
slots.current.set(tool.id, { count: slot.count + 1, lastAt: now })
|
||||||
|
turnController.pushActivity(
|
||||||
const sec = Math.round((now - tool.startedAt) / 1000)
|
`${pick(LONG_RUN_CHARMS)} (${toolTrailLabel(tool.name)} · ${Math.round((now - tool.startedAt) / 1000)}s)`
|
||||||
|
)
|
||||||
turnController.pushActivity(`${pick(LONG_RUN_CHARMS)} (${toolTrailLabel(tool.name)} · ${sec}s)`)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -170,18 +170,16 @@ export function useMainApp(gw: GatewayClient) {
|
||||||
}
|
}
|
||||||
|
|
||||||
const sel = selection.getState() as null | SelectionSnap
|
const sel = selection.getState() as null | SelectionSnap
|
||||||
|
const top = s.getViewportTop()
|
||||||
|
const bottom = top + s.getViewportHeight() - 1
|
||||||
|
|
||||||
const focusOutside = (top: number, bottom: number) =>
|
if (
|
||||||
!sel?.anchor ||
|
!sel?.anchor ||
|
||||||
!sel.focus ||
|
!sel.focus ||
|
||||||
sel.anchor.row < top ||
|
sel.anchor.row < top ||
|
||||||
sel.anchor.row > bottom ||
|
sel.anchor.row > bottom ||
|
||||||
(!sel.isDragging && (sel.focus.row < top || sel.focus.row > bottom))
|
(!sel.isDragging && (sel.focus.row < top || sel.focus.row > bottom))
|
||||||
|
) {
|
||||||
const top = s.getViewportTop()
|
|
||||||
const bottom = top + s.getViewportHeight() - 1
|
|
||||||
|
|
||||||
if (focusOutside(top, bottom)) {
|
|
||||||
return s.scrollBy(delta)
|
return s.scrollBy(delta)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -197,12 +195,11 @@ export function useMainApp(gw: GatewayClient) {
|
||||||
|
|
||||||
if (actual > 0) {
|
if (actual > 0) {
|
||||||
selection.captureScrolledRows(top, top + actual - 1, 'above')
|
selection.captureScrolledRows(top, top + actual - 1, 'above')
|
||||||
shift(-actual, top, bottom)
|
|
||||||
} else {
|
} else {
|
||||||
selection.captureScrolledRows(bottom + actual + 1, bottom, 'below')
|
selection.captureScrolledRows(bottom + actual + 1, bottom, 'below')
|
||||||
shift(-actual, top, bottom)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
shift(-actual, top, bottom)
|
||||||
s.scrollBy(delta)
|
s.scrollBy(delta)
|
||||||
},
|
},
|
||||||
[selection]
|
[selection]
|
||||||
|
|
|
||||||
|
|
@ -29,19 +29,6 @@ const expandSnips = (snips: PasteSnippet[]) => {
|
||||||
const spliceMatches = (text: string, matches: RegExpMatchArray[], results: string[]) =>
|
const spliceMatches = (text: string, matches: RegExpMatchArray[], results: string[]) =>
|
||||||
matches.reduceRight((acc, m, i) => acc.slice(0, m.index!) + results[i] + acc.slice(m.index! + m[0].length), text)
|
matches.reduceRight((acc, m, i) => acc.slice(0, m.index!) + results[i] + acc.slice(m.index! + m[0].length), text)
|
||||||
|
|
||||||
export interface UseSubmissionOptions {
|
|
||||||
appendMessage: (msg: Msg) => void
|
|
||||||
composerActions: ComposerActions
|
|
||||||
composerRefs: ComposerRefs
|
|
||||||
composerState: ComposerState
|
|
||||||
gw: GatewayClient
|
|
||||||
maybeGoodVibes: (text: string) => void
|
|
||||||
setLastUserMsg: (value: string) => void
|
|
||||||
slashRef: MutableRefObject<(cmd: string) => boolean>
|
|
||||||
submitRef: MutableRefObject<(value: string) => void>
|
|
||||||
sys: (text: string) => void
|
|
||||||
}
|
|
||||||
|
|
||||||
export function useSubmission(opts: UseSubmissionOptions) {
|
export function useSubmission(opts: UseSubmissionOptions) {
|
||||||
const {
|
const {
|
||||||
appendMessage,
|
appendMessage,
|
||||||
|
|
@ -183,7 +170,6 @@ export function useSubmission(opts: UseSubmissionOptions) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// Slash + shell run regardless of session state (each handles its own sid needs).
|
|
||||||
if (looksLikeSlashCommand(full)) {
|
if (looksLikeSlashCommand(full)) {
|
||||||
appendMessage({ kind: 'slash', role: 'system', text: full })
|
appendMessage({ kind: 'slash', role: 'system', text: full })
|
||||||
composerActions.pushHistory(full)
|
composerActions.pushHistory(full)
|
||||||
|
|
@ -201,7 +187,6 @@ export function useSubmission(opts: UseSubmissionOptions) {
|
||||||
|
|
||||||
const live = getUiState()
|
const live = getUiState()
|
||||||
|
|
||||||
// No session yet — queue the text and let the ready-flush effect send it.
|
|
||||||
if (!live.sid) {
|
if (!live.sid) {
|
||||||
composerActions.pushHistory(full)
|
composerActions.pushHistory(full)
|
||||||
composerActions.enqueue(full)
|
composerActions.enqueue(full)
|
||||||
|
|
@ -246,7 +231,7 @@ export function useSubmission(opts: UseSubmissionOptions) {
|
||||||
|
|
||||||
send(full)
|
send(full)
|
||||||
},
|
},
|
||||||
[appendMessage, composerActions, composerRefs, interpolate, send, sendQueued, shellExec, slashRef, sys]
|
[appendMessage, composerActions, composerRefs, interpolate, send, sendQueued, shellExec, slashRef]
|
||||||
)
|
)
|
||||||
|
|
||||||
const submit = useCallback(
|
const submit = useCallback(
|
||||||
|
|
@ -256,7 +241,6 @@ export function useSubmission(opts: UseSubmissionOptions) {
|
||||||
|
|
||||||
if (row?.text) {
|
if (row?.text) {
|
||||||
const text = row.text.startsWith('/') && composerState.compReplace > 0 ? row.text.slice(1) : row.text
|
const text = row.text.startsWith('/') && composerState.compReplace > 0 ? row.text.slice(1) : row.text
|
||||||
|
|
||||||
const next = value.slice(0, composerState.compReplace) + text
|
const next = value.slice(0, composerState.compReplace) + text
|
||||||
|
|
||||||
if (next !== value) {
|
if (next !== value) {
|
||||||
|
|
@ -304,3 +288,16 @@ export function useSubmission(opts: UseSubmissionOptions) {
|
||||||
|
|
||||||
return { dispatchSubmission, send, sendQueued, shellExec, submit }
|
return { dispatchSubmission, send, sendQueued, shellExec, submit }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface UseSubmissionOptions {
|
||||||
|
appendMessage: (msg: Msg) => void
|
||||||
|
composerActions: ComposerActions
|
||||||
|
composerRefs: ComposerRefs
|
||||||
|
composerState: ComposerState
|
||||||
|
gw: GatewayClient
|
||||||
|
maybeGoodVibes: (text: string) => void
|
||||||
|
setLastUserMsg: (value: string) => void
|
||||||
|
slashRef: MutableRefObject<(cmd: string) => boolean>
|
||||||
|
submitRef: MutableRefObject<(value: string) => void>
|
||||||
|
sys: (text: string) => void
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,9 +1,5 @@
|
||||||
import type { ThemeColors } from './theme.js'
|
import type { ThemeColors } from './theme.js'
|
||||||
|
|
||||||
type Line = [string, string]
|
|
||||||
|
|
||||||
// ── Rich markup parser ──────────────────────────────────────────────
|
|
||||||
// Parses Python Rich markup like "[bold #A3261F]text[/]" into Line[].
|
|
||||||
const RICH_RE = /\[(?:bold\s+)?(?:dim\s+)?(#(?:[0-9a-fA-F]{3,8}))\]([\s\S]*?)(\[\/\])/g
|
const RICH_RE = /\[(?:bold\s+)?(?:dim\s+)?(#(?:[0-9a-fA-F]{3,8}))\]([\s\S]*?)(\[\/\])/g
|
||||||
|
|
||||||
export function parseRichMarkup(markup: string): Line[] {
|
export function parseRichMarkup(markup: string): Line[] {
|
||||||
|
|
@ -18,28 +14,29 @@ export function parseRichMarkup(markup: string): Line[] {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
let lastIndex = 0
|
const matches = [...trimmed.matchAll(RICH_RE)]
|
||||||
let matched = false
|
|
||||||
let m: RegExpExecArray | null
|
|
||||||
|
|
||||||
RICH_RE.lastIndex = 0
|
if (!matches.length) {
|
||||||
|
lines.push(['', trimmed])
|
||||||
|
|
||||||
while ((m = RICH_RE.exec(trimmed)) !== null) {
|
continue
|
||||||
matched = true
|
}
|
||||||
const before = trimmed.slice(lastIndex, m.index)
|
|
||||||
|
let cursor = 0
|
||||||
|
|
||||||
|
for (const m of matches) {
|
||||||
|
const before = trimmed.slice(cursor, m.index)
|
||||||
|
|
||||||
if (before) {
|
if (before) {
|
||||||
lines.push(['', before])
|
lines.push(['', before])
|
||||||
}
|
}
|
||||||
|
|
||||||
lines.push([m[1]!, m[2]!])
|
lines.push([m[1]!, m[2]!])
|
||||||
lastIndex = m.index + m[0].length
|
cursor = m.index! + m[0].length
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!matched) {
|
if (cursor < trimmed.length) {
|
||||||
lines.push(['', trimmed])
|
lines.push(['', trimmed.slice(cursor)])
|
||||||
} else if (lastIndex < trimmed.length) {
|
|
||||||
lines.push(['', trimmed.slice(lastIndex)])
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -76,10 +73,10 @@ const CADUCEUS_ART = [
|
||||||
const LOGO_GRADIENT = [0, 0, 1, 1, 2, 2] as const
|
const LOGO_GRADIENT = [0, 0, 1, 1, 2, 2] as const
|
||||||
const CADUC_GRADIENT = [2, 2, 1, 1, 0, 0, 1, 1, 2, 2, 3, 3, 3, 3, 3] as const
|
const CADUC_GRADIENT = [2, 2, 1, 1, 0, 0, 1, 1, 2, 2, 3, 3, 3, 3, 3] as const
|
||||||
|
|
||||||
function colorize(art: string[], gradient: readonly number[], c: ThemeColors): Line[] {
|
const colorize = (art: string[], gradient: readonly number[], c: ThemeColors): Line[] => {
|
||||||
const palette = [c.gold, c.amber, c.bronze, c.dim]
|
const p = [c.gold, c.amber, c.bronze, c.dim]
|
||||||
|
|
||||||
return art.map((text, i) => [palette[gradient[i]] ?? c.dim, text])
|
return art.map((text, i) => [p[gradient[i]!] ?? c.dim, text])
|
||||||
}
|
}
|
||||||
|
|
||||||
export const LOGO_WIDTH = 98
|
export const LOGO_WIDTH = 98
|
||||||
|
|
@ -91,14 +88,6 @@ export const logo = (c: ThemeColors, customLogo?: string): Line[] =>
|
||||||
export const caduceus = (c: ThemeColors, customHero?: string): Line[] =>
|
export const caduceus = (c: ThemeColors, customHero?: string): Line[] =>
|
||||||
customHero ? parseRichMarkup(customHero) : colorize(CADUCEUS_ART, CADUC_GRADIENT, c)
|
customHero ? parseRichMarkup(customHero) : colorize(CADUCEUS_ART, CADUC_GRADIENT, c)
|
||||||
|
|
||||||
export function artWidth(lines: Line[]): number {
|
export const artWidth = (lines: Line[]) => lines.reduce((m, [, t]) => Math.max(m, t.length), 0)
|
||||||
let max = 0
|
|
||||||
|
|
||||||
for (const [, text] of lines) {
|
type Line = [string, string]
|
||||||
if (text.length > max) {
|
|
||||||
max = text.length
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return max
|
|
||||||
}
|
|
||||||
|
|
|
||||||
|
|
@ -10,6 +10,7 @@ import type { Theme } from '../theme.js'
|
||||||
import type { Msg, Usage } from '../types.js'
|
import type { Msg, Usage } from '../types.js'
|
||||||
|
|
||||||
const FACE_TICK_MS = 2500
|
const FACE_TICK_MS = 2500
|
||||||
|
const HEART_COLORS = ['#ff5fa2', '#ff4d6d']
|
||||||
|
|
||||||
function FaceTicker({ color }: { color: string }) {
|
function FaceTicker({ color }: { color: string }) {
|
||||||
const [tick, setTick] = useState(() => Math.floor(Math.random() * 1000))
|
const [tick, setTick] = useState(() => Math.floor(Math.random() * 1000))
|
||||||
|
|
@ -76,10 +77,8 @@ export function GoodVibesHeart({ tick, t }: { tick: number; t: Theme }) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
const options = ['#ff5fa2', '#ff4d6d', t.color.amber]
|
const palette = [...HEART_COLORS, t.color.amber]
|
||||||
const picked = options[Math.floor(Math.random() * options.length)]!
|
setColor(palette[Math.floor(Math.random() * palette.length)]!)
|
||||||
|
|
||||||
setColor(picked)
|
|
||||||
setActive(true)
|
setActive(true)
|
||||||
|
|
||||||
const id = setTimeout(() => setActive(false), 650)
|
const id = setTimeout(() => setActive(false), 650)
|
||||||
|
|
@ -102,19 +101,7 @@ export function StatusRule({
|
||||||
sessionStartedAt,
|
sessionStartedAt,
|
||||||
voiceLabel,
|
voiceLabel,
|
||||||
t
|
t
|
||||||
}: {
|
}: StatusRuleProps) {
|
||||||
cwdLabel: string
|
|
||||||
cols: number
|
|
||||||
busy: boolean
|
|
||||||
status: string
|
|
||||||
statusColor: string
|
|
||||||
model: string
|
|
||||||
usage: Usage
|
|
||||||
bgCount: number
|
|
||||||
sessionStartedAt?: number | null
|
|
||||||
voiceLabel?: string
|
|
||||||
t: Theme
|
|
||||||
}) {
|
|
||||||
const pct = usage.context_percent
|
const pct = usage.context_percent
|
||||||
const barColor = ctxBarColor(pct, t)
|
const barColor = ctxBarColor(pct, t)
|
||||||
|
|
||||||
|
|
@ -124,7 +111,6 @@ export function StatusRule({
|
||||||
? `${fmtK(usage.total)} tok`
|
? `${fmtK(usage.total)} tok`
|
||||||
: ''
|
: ''
|
||||||
|
|
||||||
const pctLabel = pct != null ? `${pct}%` : ''
|
|
||||||
const bar = usage.context_max ? ctxBar(pct) : ''
|
const bar = usage.context_max ? ctxBar(pct) : ''
|
||||||
const leftWidth = Math.max(12, cols - cwdLabel.length - 3)
|
const leftWidth = Math.max(12, cols - cwdLabel.length - 3)
|
||||||
|
|
||||||
|
|
@ -139,7 +125,7 @@ export function StatusRule({
|
||||||
{bar ? (
|
{bar ? (
|
||||||
<Text color={t.color.dim}>
|
<Text color={t.color.dim}>
|
||||||
{' │ '}
|
{' │ '}
|
||||||
<Text color={barColor}>[{bar}]</Text> <Text color={barColor}>{pctLabel}</Text>
|
<Text color={barColor}>[{bar}]</Text> <Text color={barColor}>{pct != null ? `${pct}%` : ''}</Text>
|
||||||
</Text>
|
</Text>
|
||||||
) : null}
|
) : null}
|
||||||
{sessionStartedAt ? (
|
{sessionStartedAt ? (
|
||||||
|
|
@ -152,6 +138,7 @@ export function StatusRule({
|
||||||
{bgCount > 0 ? <Text color={t.color.dim}> │ {bgCount} bg</Text> : null}
|
{bgCount > 0 ? <Text color={t.color.dim}> │ {bgCount} bg</Text> : null}
|
||||||
</Text>
|
</Text>
|
||||||
</Box>
|
</Box>
|
||||||
|
|
||||||
<Text color={t.color.bronze}> ─ </Text>
|
<Text color={t.color.bronze}> ─ </Text>
|
||||||
<Text color={t.color.label}>{cwdLabel}</Text>
|
<Text color={t.color.label}>{cwdLabel}</Text>
|
||||||
</Box>
|
</Box>
|
||||||
|
|
@ -174,17 +161,7 @@ export function FloatBox({ children, color }: { children: ReactNode; color: stri
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
export function StickyPromptTracker({
|
export function StickyPromptTracker({ messages, offsets, scrollRef, onChange }: StickyPromptTrackerProps) {
|
||||||
messages,
|
|
||||||
offsets,
|
|
||||||
scrollRef,
|
|
||||||
onChange
|
|
||||||
}: {
|
|
||||||
messages: readonly Msg[]
|
|
||||||
offsets: ArrayLike<number>
|
|
||||||
scrollRef: RefObject<ScrollBoxHandle | null>
|
|
||||||
onChange: (text: string) => void
|
|
||||||
}) {
|
|
||||||
useSyncExternalStore(
|
useSyncExternalStore(
|
||||||
useCallback((cb: () => void) => scrollRef.current?.subscribe(cb) ?? (() => {}), [scrollRef]),
|
useCallback((cb: () => void) => scrollRef.current?.subscribe(cb) ?? (() => {}), [scrollRef]),
|
||||||
() => {
|
() => {
|
||||||
|
|
@ -210,13 +187,9 @@ export function StickyPromptTracker({
|
||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
|
|
||||||
export function TranscriptScrollbar({ scrollRef, t }: { scrollRef: RefObject<ScrollBoxHandle | null>; t: Theme }) {
|
export function TranscriptScrollbar({ scrollRef, t }: TranscriptScrollbarProps) {
|
||||||
useSyncExternalStore(
|
useSyncExternalStore(
|
||||||
useCallback((cb: () => void) => scrollRef.current?.subscribe(cb) ?? (() => {}), [scrollRef]),
|
useCallback((cb: () => void) => scrollRef.current?.subscribe(cb) ?? (() => {}), [scrollRef]),
|
||||||
// Quantize the scroll snapshot to the values the thumb actually renders
|
|
||||||
// with — thumbTop + thumbSize + viewport height. Streaming drives
|
|
||||||
// scrollHeight up by ~1 row at a time, but the quantized thumb usually
|
|
||||||
// doesn't move, so we skip thousands of render cycles mid-turn.
|
|
||||||
() => {
|
() => {
|
||||||
const s = scrollRef.current
|
const s = scrollRef.current
|
||||||
|
|
||||||
|
|
@ -304,3 +277,29 @@ export function TranscriptScrollbar({ scrollRef, t }: { scrollRef: RefObject<Scr
|
||||||
</Box>
|
</Box>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface StatusRuleProps {
|
||||||
|
bgCount: number
|
||||||
|
busy: boolean
|
||||||
|
cols: number
|
||||||
|
cwdLabel: string
|
||||||
|
model: string
|
||||||
|
sessionStartedAt?: number | null
|
||||||
|
status: string
|
||||||
|
statusColor: string
|
||||||
|
t: Theme
|
||||||
|
usage: Usage
|
||||||
|
voiceLabel?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
interface StickyPromptTrackerProps {
|
||||||
|
messages: readonly Msg[]
|
||||||
|
offsets: ArrayLike<number>
|
||||||
|
onChange: (text: string) => void
|
||||||
|
scrollRef: RefObject<ScrollBoxHandle | null>
|
||||||
|
}
|
||||||
|
|
||||||
|
interface TranscriptScrollbarProps {
|
||||||
|
scrollRef: RefObject<ScrollBoxHandle | null>
|
||||||
|
t: Theme
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -24,20 +24,13 @@ const StreamingAssistant = memo(function StreamingAssistant({
|
||||||
detailsMode,
|
detailsMode,
|
||||||
progress,
|
progress,
|
||||||
t
|
t
|
||||||
}: {
|
}: StreamingAssistantProps) {
|
||||||
busy: boolean
|
|
||||||
cols: number
|
|
||||||
compact?: boolean
|
|
||||||
detailsMode: DetailsMode
|
|
||||||
progress: AppLayoutProgressProps
|
|
||||||
t: Theme
|
|
||||||
}) {
|
|
||||||
if (!progress.showProgressArea && !progress.showStreamingArea) {
|
if (!progress.showProgressArea && !progress.showStreamingArea) {
|
||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Box flexDirection="column">
|
<>
|
||||||
{progress.showProgressArea && (
|
{progress.showProgressArea && (
|
||||||
<Box flexDirection="column" marginBottom={progress.showStreamingArea ? 1 : 0}>
|
<Box flexDirection="column" marginBottom={progress.showStreamingArea ? 1 : 0}>
|
||||||
<ToolTrail
|
<ToolTrail
|
||||||
|
|
@ -67,7 +60,7 @@ const StreamingAssistant = memo(function StreamingAssistant({
|
||||||
t={t}
|
t={t}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</Box>
|
</>
|
||||||
)
|
)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|
@ -79,15 +72,13 @@ const TranscriptPane = memo(function TranscriptPane({
|
||||||
}: Pick<AppLayoutProps, 'actions' | 'composer' | 'progress' | 'transcript'>) {
|
}: Pick<AppLayoutProps, 'actions' | 'composer' | 'progress' | 'transcript'>) {
|
||||||
const ui = useStore($uiState)
|
const ui = useStore($uiState)
|
||||||
|
|
||||||
const visibleHistory = transcript.virtualRows.slice(transcript.virtualHistory.start, transcript.virtualHistory.end)
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<ScrollBox flexDirection="column" flexGrow={1} flexShrink={1} ref={transcript.scrollRef} stickyScroll>
|
<ScrollBox flexDirection="column" flexGrow={1} flexShrink={1} ref={transcript.scrollRef} stickyScroll>
|
||||||
<Box flexDirection="column" paddingX={1}>
|
<Box flexDirection="column" paddingX={1}>
|
||||||
{transcript.virtualHistory.topSpacer > 0 ? <Box height={transcript.virtualHistory.topSpacer} /> : null}
|
{transcript.virtualHistory.topSpacer > 0 ? <Box height={transcript.virtualHistory.topSpacer} /> : null}
|
||||||
|
|
||||||
{visibleHistory.map(row => (
|
{transcript.virtualRows.slice(transcript.virtualHistory.start, transcript.virtualHistory.end).map(row => (
|
||||||
<Box flexDirection="column" key={row.key} ref={transcript.virtualHistory.measureRef(row.key)}>
|
<Box flexDirection="column" key={row.key} ref={transcript.virtualHistory.measureRef(row.key)}>
|
||||||
{row.msg.kind === 'intro' ? (
|
{row.msg.kind === 'intro' ? (
|
||||||
<Box flexDirection="column" paddingTop={1}>
|
<Box flexDirection="column" paddingTop={1}>
|
||||||
|
|
@ -234,6 +225,7 @@ const ComposerPane = memo(function ComposerPane({
|
||||||
placeholder={composer.empty ? PLACEHOLDER : ui.busy ? 'Ctrl+C to interrupt…' : ''}
|
placeholder={composer.empty ? PLACEHOLDER : ui.busy ? 'Ctrl+C to interrupt…' : ''}
|
||||||
value={composer.input}
|
value={composer.input}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<Box position="absolute" right={0}>
|
<Box position="absolute" right={0}>
|
||||||
<GoodVibesHeart t={ui.theme} tick={status.goodVibesTick} />
|
<GoodVibesHeart t={ui.theme} tick={status.goodVibesTick} />
|
||||||
</Box>
|
</Box>
|
||||||
|
|
@ -267,3 +259,12 @@ export const AppLayout = memo(function AppLayout({
|
||||||
</AlternateScreen>
|
</AlternateScreen>
|
||||||
)
|
)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
interface StreamingAssistantProps {
|
||||||
|
busy: boolean
|
||||||
|
cols: number
|
||||||
|
compact?: boolean
|
||||||
|
detailsMode: DetailsMode
|
||||||
|
progress: AppLayoutProgressProps
|
||||||
|
t: Theme
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -28,8 +28,7 @@ export function AppOverlays({
|
||||||
const overlay = useStore($overlayState)
|
const overlay = useStore($overlayState)
|
||||||
const ui = useStore($uiState)
|
const ui = useStore($uiState)
|
||||||
|
|
||||||
if (
|
const hasAny =
|
||||||
!(
|
|
||||||
overlay.approval ||
|
overlay.approval ||
|
||||||
overlay.clarify ||
|
overlay.clarify ||
|
||||||
overlay.modelPicker ||
|
overlay.modelPicker ||
|
||||||
|
|
@ -38,8 +37,8 @@ export function AppOverlays({
|
||||||
overlay.secret ||
|
overlay.secret ||
|
||||||
overlay.sudo ||
|
overlay.sudo ||
|
||||||
completions.length
|
completions.length
|
||||||
)
|
|
||||||
) {
|
if (!hasAny) {
|
||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -20,11 +20,10 @@ export function ArtLines({ lines }: { lines: [string, string][] }) {
|
||||||
export function Banner({ t }: { t: Theme }) {
|
export function Banner({ t }: { t: Theme }) {
|
||||||
const cols = useStdout().stdout?.columns ?? 80
|
const cols = useStdout().stdout?.columns ?? 80
|
||||||
const logoLines = logo(t.color, t.bannerLogo || undefined)
|
const logoLines = logo(t.color, t.bannerLogo || undefined)
|
||||||
const logoW = t.bannerLogo ? artWidth(logoLines) : LOGO_WIDTH
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Box flexDirection="column" marginBottom={1}>
|
<Box flexDirection="column" marginBottom={1}>
|
||||||
{cols >= logoW ? (
|
{cols >= (t.bannerLogo ? artWidth(logoLines) : LOGO_WIDTH) ? (
|
||||||
<ArtLines lines={logoLines} />
|
<ArtLines lines={logoLines} />
|
||||||
) : (
|
) : (
|
||||||
<Text bold color={t.color.gold}>
|
<Text bold color={t.color.gold}>
|
||||||
|
|
@ -37,18 +36,14 @@ export function Banner({ t }: { t: Theme }) {
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
export function SessionPanel({ info, sid, t }: { info: SessionInfo; sid?: string | null; t: Theme }) {
|
export function SessionPanel({ info, sid, t }: SessionPanelProps) {
|
||||||
const cols = useStdout().stdout?.columns ?? 100
|
const cols = useStdout().stdout?.columns ?? 100
|
||||||
const heroLines = caduceus(t.color, t.bannerHero || undefined)
|
const heroLines = caduceus(t.color, t.bannerHero || undefined)
|
||||||
const heroW = artWidth(heroLines) || CADUCEUS_WIDTH
|
const leftW = Math.min((artWidth(heroLines) || CADUCEUS_WIDTH) + 4, Math.floor(cols * 0.4))
|
||||||
const leftW = Math.min(heroW + 4, Math.floor(cols * 0.4))
|
|
||||||
const wide = cols >= 90 && leftW + 40 < cols
|
const wide = cols >= 90 && leftW + 40 < cols
|
||||||
// Keep an explicit gutter so right border never gets overwritten by long lines.
|
|
||||||
const w = Math.max(20, wide ? cols - leftW - 14 : cols - 12)
|
const w = Math.max(20, wide ? cols - leftW - 14 : cols - 12)
|
||||||
const lineBudget = Math.max(12, w - 2)
|
const lineBudget = Math.max(12, w - 2)
|
||||||
const cwd = info.cwd || process.cwd()
|
|
||||||
const strip = (s: string) => (s.endsWith('_tools') ? s.slice(0, -6) : s)
|
const strip = (s: string) => (s.endsWith('_tools') ? s.slice(0, -6) : s)
|
||||||
const title = `${t.brand.name}${info.version ? ` v${info.version}` : ''}${info.release_date ? ` (${info.release_date})` : ''}`
|
|
||||||
|
|
||||||
const truncLine = (pfx: string, items: string[]) => {
|
const truncLine = (pfx: string, items: string[]) => {
|
||||||
let line = ''
|
let line = ''
|
||||||
|
|
@ -78,12 +73,14 @@ export function SessionPanel({ info, sid, t }: { info: SessionInfo; sid?: string
|
||||||
<Text bold color={t.color.amber}>
|
<Text bold color={t.color.amber}>
|
||||||
Available {title}
|
Available {title}
|
||||||
</Text>
|
</Text>
|
||||||
|
|
||||||
{shown.map(([k, vs]) => (
|
{shown.map(([k, vs]) => (
|
||||||
<Text key={k} wrap="truncate">
|
<Text key={k} wrap="truncate">
|
||||||
<Text color={t.color.dim}>{strip(k)}: </Text>
|
<Text color={t.color.dim}>{strip(k)}: </Text>
|
||||||
<Text color={t.color.cornsilk}>{truncLine(strip(k) + ': ', vs)}</Text>
|
<Text color={t.color.cornsilk}>{truncLine(strip(k) + ': ', vs)}</Text>
|
||||||
</Text>
|
</Text>
|
||||||
))}
|
))}
|
||||||
|
|
||||||
{overflow > 0 && (
|
{overflow > 0 && (
|
||||||
<Text color={t.color.dim}>
|
<Text color={t.color.dim}>
|
||||||
(and {overflow} {overflowLabel})
|
(and {overflow} {overflowLabel})
|
||||||
|
|
@ -99,13 +96,16 @@ export function SessionPanel({ info, sid, t }: { info: SessionInfo; sid?: string
|
||||||
<Box flexDirection="column" marginRight={2} width={leftW}>
|
<Box flexDirection="column" marginRight={2} width={leftW}>
|
||||||
<ArtLines lines={heroLines} />
|
<ArtLines lines={heroLines} />
|
||||||
<Text />
|
<Text />
|
||||||
|
|
||||||
<Text color={t.color.amber}>
|
<Text color={t.color.amber}>
|
||||||
{info.model.split('/').pop()}
|
{info.model.split('/').pop()}
|
||||||
<Text color={t.color.dim}> · Nous Research</Text>
|
<Text color={t.color.dim}> · Nous Research</Text>
|
||||||
</Text>
|
</Text>
|
||||||
|
|
||||||
<Text color={t.color.dim} wrap="truncate-end">
|
<Text color={t.color.dim} wrap="truncate-end">
|
||||||
{cwd}
|
{info.cwd || process.cwd()}
|
||||||
</Text>
|
</Text>
|
||||||
|
|
||||||
{sid && (
|
{sid && (
|
||||||
<Text>
|
<Text>
|
||||||
<Text color={t.color.sessionLabel}>Session: </Text>
|
<Text color={t.color.sessionLabel}>Session: </Text>
|
||||||
|
|
@ -114,21 +114,27 @@ export function SessionPanel({ info, sid, t }: { info: SessionInfo; sid?: string
|
||||||
)}
|
)}
|
||||||
</Box>
|
</Box>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<Box flexDirection="column" width={w}>
|
<Box flexDirection="column" width={w}>
|
||||||
<Box justifyContent="center" marginBottom={1}>
|
<Box justifyContent="center" marginBottom={1}>
|
||||||
<Text bold color={t.color.gold}>
|
<Text bold color={t.color.gold}>
|
||||||
{title}
|
{t.brand.name}
|
||||||
|
{info.version ? ` v${info.version}` : ''}
|
||||||
|
{info.release_date ? ` (${info.release_date})` : ''}
|
||||||
</Text>
|
</Text>
|
||||||
</Box>
|
</Box>
|
||||||
|
|
||||||
{section('Tools', info.tools, 8, 'more toolsets…')}
|
{section('Tools', info.tools, 8, 'more toolsets…')}
|
||||||
{section('Skills', info.skills)}
|
{section('Skills', info.skills)}
|
||||||
<Text />
|
<Text />
|
||||||
|
|
||||||
<Text color={t.color.cornsilk}>
|
<Text color={t.color.cornsilk}>
|
||||||
{flat(info.tools).length} tools{' · '}
|
{flat(info.tools).length} tools{' · '}
|
||||||
{flat(info.skills).length} skills
|
{flat(info.skills).length} skills
|
||||||
{' · '}
|
{' · '}
|
||||||
<Text color={t.color.dim}>/help for commands</Text>
|
<Text color={t.color.dim}>/help for commands</Text>
|
||||||
</Text>
|
</Text>
|
||||||
|
|
||||||
{typeof info.update_behind === 'number' && info.update_behind > 0 && (
|
{typeof info.update_behind === 'number' && info.update_behind > 0 && (
|
||||||
<Text bold color="yellow">
|
<Text bold color="yellow">
|
||||||
! {info.update_behind} {info.update_behind === 1 ? 'commit' : 'commits'} behind
|
! {info.update_behind} {info.update_behind === 1 ? 'commit' : 'commits'} behind
|
||||||
|
|
@ -150,7 +156,7 @@ export function SessionPanel({ info, sid, t }: { info: SessionInfo; sid?: string
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
export function Panel({ sections, t, title }: { sections: PanelSection[]; t: Theme; title: string }) {
|
export function Panel({ sections, t, title }: PanelProps) {
|
||||||
return (
|
return (
|
||||||
<Box borderColor={t.color.bronze} borderStyle="round" flexDirection="column" paddingX={2} paddingY={1}>
|
<Box borderColor={t.color.bronze} borderStyle="round" flexDirection="column" paddingX={2} paddingY={1}>
|
||||||
<Box justifyContent="center" marginBottom={1}>
|
<Box justifyContent="center" marginBottom={1}>
|
||||||
|
|
@ -186,3 +192,15 @@ export function Panel({ sections, t, title }: { sections: PanelSection[]; t: The
|
||||||
</Box>
|
</Box>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface PanelProps {
|
||||||
|
sections: PanelSection[]
|
||||||
|
t: Theme
|
||||||
|
title: string
|
||||||
|
}
|
||||||
|
|
||||||
|
interface SessionPanelProps {
|
||||||
|
info: SessionInfo
|
||||||
|
sid?: string | null
|
||||||
|
t: Theme
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -5,21 +5,7 @@ import type { Theme } from '../theme.js'
|
||||||
|
|
||||||
import { TextInput } from './textInput.js'
|
import { TextInput } from './textInput.js'
|
||||||
|
|
||||||
export function MaskedPrompt({
|
export function MaskedPrompt({ cols = 80, icon, label, onSubmit, sub, t }: MaskedPromptProps) {
|
||||||
cols = 80,
|
|
||||||
icon,
|
|
||||||
label,
|
|
||||||
onSubmit,
|
|
||||||
sub,
|
|
||||||
t
|
|
||||||
}: {
|
|
||||||
cols?: number
|
|
||||||
icon: string
|
|
||||||
label: string
|
|
||||||
onSubmit: (v: string) => void
|
|
||||||
sub?: string
|
|
||||||
t: Theme
|
|
||||||
}) {
|
|
||||||
const [value, setValue] = useState('')
|
const [value, setValue] = useState('')
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|
@ -27,6 +13,7 @@ export function MaskedPrompt({
|
||||||
<Text bold color={t.color.warn}>
|
<Text bold color={t.color.warn}>
|
||||||
{icon} {label}
|
{icon} {label}
|
||||||
</Text>
|
</Text>
|
||||||
|
|
||||||
{sub && <Text color={t.color.dim}> {sub}</Text>}
|
{sub && <Text color={t.color.dim}> {sub}</Text>}
|
||||||
|
|
||||||
<Box>
|
<Box>
|
||||||
|
|
@ -36,3 +23,12 @@ export function MaskedPrompt({
|
||||||
</Box>
|
</Box>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface MaskedPromptProps {
|
||||||
|
cols?: number
|
||||||
|
icon: string
|
||||||
|
label: string
|
||||||
|
onSubmit: (v: string) => void
|
||||||
|
sub?: string
|
||||||
|
t: Theme
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -18,14 +18,7 @@ export const MessageLine = memo(function MessageLine({
|
||||||
isStreaming = false,
|
isStreaming = false,
|
||||||
msg,
|
msg,
|
||||||
t
|
t
|
||||||
}: {
|
}: MessageLineProps) {
|
||||||
cols: number
|
|
||||||
compact?: boolean
|
|
||||||
detailsMode?: DetailsMode
|
|
||||||
isStreaming?: boolean
|
|
||||||
msg: Msg
|
|
||||||
t: Theme
|
|
||||||
}) {
|
|
||||||
if (msg.kind === 'trail' && msg.tools?.length) {
|
if (msg.kind === 'trail' && msg.tools?.length) {
|
||||||
return detailsMode === 'hidden' ? null : (
|
return detailsMode === 'hidden' ? null : (
|
||||||
<Box flexDirection="column" marginTop={1}>
|
<Box flexDirection="column" marginTop={1}>
|
||||||
|
|
@ -110,3 +103,12 @@ export const MessageLine = memo(function MessageLine({
|
||||||
</Box>
|
</Box>
|
||||||
)
|
)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
interface MessageLineProps {
|
||||||
|
cols: number
|
||||||
|
compact?: boolean
|
||||||
|
detailsMode?: DetailsMode
|
||||||
|
isStreaming?: boolean
|
||||||
|
msg: Msg
|
||||||
|
t: Theme
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -10,19 +10,13 @@ const VISIBLE = 12
|
||||||
|
|
||||||
const pageOffset = (count: number, sel: number) => Math.max(0, Math.min(sel - Math.floor(VISIBLE / 2), count - VISIBLE))
|
const pageOffset = (count: number, sel: number) => Math.max(0, Math.min(sel - Math.floor(VISIBLE / 2), count - VISIBLE))
|
||||||
|
|
||||||
export function ModelPicker({
|
const visibleItems = (items: string[], sel: number) => {
|
||||||
gw,
|
const off = pageOffset(items.length, sel)
|
||||||
onCancel,
|
|
||||||
onSelect,
|
return { items: items.slice(off, off + VISIBLE), off }
|
||||||
sessionId,
|
}
|
||||||
t
|
|
||||||
}: {
|
export function ModelPicker({ gw, onCancel, onSelect, sessionId, t }: ModelPickerProps) {
|
||||||
gw: GatewayClient
|
|
||||||
onCancel: () => void
|
|
||||||
onSelect: (value: string) => void
|
|
||||||
sessionId: string | null
|
|
||||||
t: Theme
|
|
||||||
}) {
|
|
||||||
const [providers, setProviders] = useState<ModelOptionProvider[]>([])
|
const [providers, setProviders] = useState<ModelOptionProvider[]>([])
|
||||||
const [currentModel, setCurrentModel] = useState('')
|
const [currentModel, setCurrentModel] = useState('')
|
||||||
const [err, setErr] = useState('')
|
const [err, setErr] = useState('')
|
||||||
|
|
@ -66,12 +60,6 @@ export function ModelPicker({
|
||||||
const provider = providers[providerIdx]
|
const provider = providers[providerIdx]
|
||||||
const models = provider?.models ?? []
|
const models = provider?.models ?? []
|
||||||
|
|
||||||
const visibleItems = (items: string[], sel: number) => {
|
|
||||||
const off = pageOffset(items.length, sel)
|
|
||||||
|
|
||||||
return { items: items.slice(off, off + VISIBLE), off }
|
|
||||||
}
|
|
||||||
|
|
||||||
useInput((ch, key) => {
|
useInput((ch, key) => {
|
||||||
if (key.escape) {
|
if (key.escape) {
|
||||||
if (stage === 'model') {
|
if (stage === 'model') {
|
||||||
|
|
@ -182,9 +170,11 @@ export function ModelPicker({
|
||||||
<Text bold color={t.color.amber}>
|
<Text bold color={t.color.amber}>
|
||||||
Select Provider
|
Select Provider
|
||||||
</Text>
|
</Text>
|
||||||
|
|
||||||
<Text color={t.color.dim}>Current model: {currentModel || '(unknown)'}</Text>
|
<Text color={t.color.dim}>Current model: {currentModel || '(unknown)'}</Text>
|
||||||
{provider?.warning ? <Text color={t.color.label}>warning: {provider.warning}</Text> : null}
|
{provider?.warning ? <Text color={t.color.label}>warning: {provider.warning}</Text> : null}
|
||||||
{off > 0 && <Text color={t.color.dim}> ↑ {off} more</Text>}
|
{off > 0 && <Text color={t.color.dim}> ↑ {off} more</Text>}
|
||||||
|
|
||||||
{items.map((row, i) => {
|
{items.map((row, i) => {
|
||||||
const idx = off + i
|
const idx = off + i
|
||||||
|
|
||||||
|
|
@ -195,6 +185,7 @@ export function ModelPicker({
|
||||||
</Text>
|
</Text>
|
||||||
)
|
)
|
||||||
})}
|
})}
|
||||||
|
|
||||||
{off + VISIBLE < rows.length && <Text color={t.color.dim}> ↓ {rows.length - off - VISIBLE} more</Text>}
|
{off + VISIBLE < rows.length && <Text color={t.color.dim}> ↓ {rows.length - off - VISIBLE} more</Text>}
|
||||||
<Text color={t.color.dim}>persist: {persistGlobal ? 'global' : 'session'} · g toggle</Text>
|
<Text color={t.color.dim}>persist: {persistGlobal ? 'global' : 'session'} · g toggle</Text>
|
||||||
<Text color={t.color.dim}>↑/↓ select · Enter choose · 1-9,0 quick · Esc cancel</Text>
|
<Text color={t.color.dim}>↑/↓ select · Enter choose · 1-9,0 quick · Esc cancel</Text>
|
||||||
|
|
@ -209,10 +200,12 @@ export function ModelPicker({
|
||||||
<Text bold color={t.color.amber}>
|
<Text bold color={t.color.amber}>
|
||||||
Select Model
|
Select Model
|
||||||
</Text>
|
</Text>
|
||||||
|
|
||||||
<Text color={t.color.dim}>{provider?.name || '(unknown provider)'}</Text>
|
<Text color={t.color.dim}>{provider?.name || '(unknown provider)'}</Text>
|
||||||
{!models.length ? <Text color={t.color.dim}>no models listed for this provider</Text> : null}
|
{!models.length ? <Text color={t.color.dim}>no models listed for this provider</Text> : null}
|
||||||
{provider?.warning ? <Text color={t.color.label}>warning: {provider.warning}</Text> : null}
|
{provider?.warning ? <Text color={t.color.label}>warning: {provider.warning}</Text> : null}
|
||||||
{off > 0 && <Text color={t.color.dim}> ↑ {off} more</Text>}
|
{off > 0 && <Text color={t.color.dim}> ↑ {off} more</Text>}
|
||||||
|
|
||||||
{items.map((row, i) => {
|
{items.map((row, i) => {
|
||||||
const idx = off + i
|
const idx = off + i
|
||||||
|
|
||||||
|
|
@ -223,6 +216,7 @@ export function ModelPicker({
|
||||||
</Text>
|
</Text>
|
||||||
)
|
)
|
||||||
})}
|
})}
|
||||||
|
|
||||||
{off + VISIBLE < models.length && <Text color={t.color.dim}> ↓ {models.length - off - VISIBLE} more</Text>}
|
{off + VISIBLE < models.length && <Text color={t.color.dim}> ↓ {models.length - off - VISIBLE} more</Text>}
|
||||||
<Text color={t.color.dim}>persist: {persistGlobal ? 'global' : 'session'} · g toggle</Text>
|
<Text color={t.color.dim}>persist: {persistGlobal ? 'global' : 'session'} · g toggle</Text>
|
||||||
<Text color={t.color.dim}>
|
<Text color={t.color.dim}>
|
||||||
|
|
@ -231,3 +225,11 @@ export function ModelPicker({
|
||||||
</Box>
|
</Box>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface ModelPickerProps {
|
||||||
|
gw: GatewayClient
|
||||||
|
onCancel: () => void
|
||||||
|
onSelect: (value: string) => void
|
||||||
|
sessionId: string | null
|
||||||
|
t: Theme
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -6,10 +6,11 @@ import type { ApprovalReq, ClarifyReq } from '../types.js'
|
||||||
|
|
||||||
import { TextInput } from './textInput.js'
|
import { TextInput } from './textInput.js'
|
||||||
|
|
||||||
export function ApprovalPrompt({ onChoice, req, t }: { onChoice: (s: string) => void; req: ApprovalReq; t: Theme }) {
|
const OPTS = ['once', 'session', 'always', 'deny'] as const
|
||||||
|
const LABELS = { always: 'Always allow', deny: 'Deny', once: 'Allow once', session: 'Allow this session' } as const
|
||||||
|
|
||||||
|
export function ApprovalPrompt({ onChoice, req, t }: ApprovalPromptProps) {
|
||||||
const [sel, setSel] = useState(3)
|
const [sel, setSel] = useState(3)
|
||||||
const opts = ['once', 'session', 'always', 'deny'] as const
|
|
||||||
const labels = { always: 'Always allow', deny: 'Deny', once: 'Allow once', session: 'Allow this session' } as const
|
|
||||||
|
|
||||||
useInput((ch, key) => {
|
useInput((ch, key) => {
|
||||||
if (key.upArrow && sel > 0) {
|
if (key.upArrow && sel > 0) {
|
||||||
|
|
@ -21,7 +22,7 @@ export function ApprovalPrompt({ onChoice, req, t }: { onChoice: (s: string) =>
|
||||||
}
|
}
|
||||||
|
|
||||||
if (key.return) {
|
if (key.return) {
|
||||||
onChoice(opts[sel]!)
|
onChoice(OPTS[sel]!)
|
||||||
}
|
}
|
||||||
|
|
||||||
if (ch === 'o') {
|
if (ch === 'o') {
|
||||||
|
|
@ -46,34 +47,25 @@ export function ApprovalPrompt({ onChoice, req, t }: { onChoice: (s: string) =>
|
||||||
<Text bold color={t.color.warn}>
|
<Text bold color={t.color.warn}>
|
||||||
! DANGEROUS COMMAND: {req.description}
|
! DANGEROUS COMMAND: {req.description}
|
||||||
</Text>
|
</Text>
|
||||||
|
|
||||||
<Text color={t.color.dim}> {req.command}</Text>
|
<Text color={t.color.dim}> {req.command}</Text>
|
||||||
<Text />
|
<Text />
|
||||||
{opts.map((o, i) => (
|
|
||||||
|
{OPTS.map((o, i) => (
|
||||||
<Text key={o}>
|
<Text key={o}>
|
||||||
<Text color={sel === i ? t.color.warn : t.color.dim}>{sel === i ? '▸ ' : ' '}</Text>
|
<Text color={sel === i ? t.color.warn : t.color.dim}>{sel === i ? '▸ ' : ' '}</Text>
|
||||||
<Text color={sel === i ? t.color.cornsilk : t.color.dim}>
|
<Text color={sel === i ? t.color.cornsilk : t.color.dim}>
|
||||||
[{o[0]}] {labels[o]}
|
[{o[0]}] {LABELS[o]}
|
||||||
</Text>
|
</Text>
|
||||||
</Text>
|
</Text>
|
||||||
))}
|
))}
|
||||||
|
|
||||||
<Text color={t.color.dim}>↑/↓ select · Enter confirm · o/s/a/d quick pick</Text>
|
<Text color={t.color.dim}>↑/↓ select · Enter confirm · o/s/a/d quick pick</Text>
|
||||||
</Box>
|
</Box>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
export function ClarifyPrompt({
|
export function ClarifyPrompt({ cols = 80, onAnswer, onCancel, req, t }: ClarifyPromptProps) {
|
||||||
cols = 80,
|
|
||||||
onAnswer,
|
|
||||||
onCancel,
|
|
||||||
req,
|
|
||||||
t
|
|
||||||
}: {
|
|
||||||
cols?: number
|
|
||||||
onAnswer: (s: string) => void
|
|
||||||
onCancel: () => void
|
|
||||||
req: ClarifyReq
|
|
||||||
t: Theme
|
|
||||||
}) {
|
|
||||||
const [sel, setSel] = useState(0)
|
const [sel, setSel] = useState(0)
|
||||||
const [custom, setCustom] = useState('')
|
const [custom, setCustom] = useState('')
|
||||||
const [typing, setTyping] = useState(false)
|
const [typing, setTyping] = useState(false)
|
||||||
|
|
@ -117,8 +109,6 @@ export function ClarifyPrompt({
|
||||||
})
|
})
|
||||||
|
|
||||||
if (typing || !choices.length) {
|
if (typing || !choices.length) {
|
||||||
const hint = choices.length ? 'Enter send · Esc back · Ctrl+C cancel' : 'Enter send · Esc cancel · Ctrl+C cancel'
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Box flexDirection="column">
|
<Box flexDirection="column">
|
||||||
{heading}
|
{heading}
|
||||||
|
|
@ -128,7 +118,7 @@ export function ClarifyPrompt({
|
||||||
<TextInput columns={Math.max(20, cols - 6)} onChange={setCustom} onSubmit={onAnswer} value={custom} />
|
<TextInput columns={Math.max(20, cols - 6)} onChange={setCustom} onSubmit={onAnswer} value={custom} />
|
||||||
</Box>
|
</Box>
|
||||||
|
|
||||||
<Text color={t.color.dim}>{hint}</Text>
|
<Text color={t.color.dim}>Enter send · Esc {choices.length ? 'back' : 'cancel'} · Ctrl+C cancel</Text>
|
||||||
</Box>
|
</Box>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
@ -150,3 +140,17 @@ export function ClarifyPrompt({
|
||||||
</Box>
|
</Box>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface ApprovalPromptProps {
|
||||||
|
onChoice: (s: string) => void
|
||||||
|
req: ApprovalReq
|
||||||
|
t: Theme
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ClarifyPromptProps {
|
||||||
|
cols?: number
|
||||||
|
onAnswer: (s: string) => void
|
||||||
|
onCancel: () => void
|
||||||
|
req: ClarifyReq
|
||||||
|
t: Theme
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -14,17 +14,7 @@ export function getQueueWindow(queueLen: number, queueEditIdx: number | null) {
|
||||||
return { end, showLead: start > 0, showTail: end < queueLen, start }
|
return { end, showLead: start > 0, showTail: end < queueLen, start }
|
||||||
}
|
}
|
||||||
|
|
||||||
export function QueuedMessages({
|
export function QueuedMessages({ cols, queueEditIdx, queued, t }: QueuedMessagesProps) {
|
||||||
cols,
|
|
||||||
queueEditIdx,
|
|
||||||
queued,
|
|
||||||
t
|
|
||||||
}: {
|
|
||||||
cols: number
|
|
||||||
queueEditIdx: number | null
|
|
||||||
queued: string[]
|
|
||||||
t: Theme
|
|
||||||
}) {
|
|
||||||
if (!queued.length) {
|
if (!queued.length) {
|
||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
|
|
@ -36,12 +26,14 @@ export function QueuedMessages({
|
||||||
<Text color={t.color.dim} dimColor>
|
<Text color={t.color.dim} dimColor>
|
||||||
queued ({queued.length}){queueEditIdx !== null ? ` · editing ${queueEditIdx + 1}` : ''}
|
queued ({queued.length}){queueEditIdx !== null ? ` · editing ${queueEditIdx + 1}` : ''}
|
||||||
</Text>
|
</Text>
|
||||||
|
|
||||||
{q.showLead && (
|
{q.showLead && (
|
||||||
<Text color={t.color.dim} dimColor>
|
<Text color={t.color.dim} dimColor>
|
||||||
{' '}
|
{' '}
|
||||||
…
|
…
|
||||||
</Text>
|
</Text>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{queued.slice(q.start, q.end).map((item, i) => {
|
{queued.slice(q.start, q.end).map((item, i) => {
|
||||||
const idx = q.start + i
|
const idx = q.start + i
|
||||||
const active = queueEditIdx === idx
|
const active = queueEditIdx === idx
|
||||||
|
|
@ -52,6 +44,7 @@ export function QueuedMessages({
|
||||||
</Text>
|
</Text>
|
||||||
)
|
)
|
||||||
})}
|
})}
|
||||||
|
|
||||||
{q.showTail && (
|
{q.showTail && (
|
||||||
<Text color={t.color.dim} dimColor>
|
<Text color={t.color.dim} dimColor>
|
||||||
{' '}…and {queued.length - q.end} more
|
{' '}…and {queued.length - q.end} more
|
||||||
|
|
@ -60,3 +53,10 @@ export function QueuedMessages({
|
||||||
</Box>
|
</Box>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface QueuedMessagesProps {
|
||||||
|
cols: number
|
||||||
|
queueEditIdx: number | null
|
||||||
|
queued: string[]
|
||||||
|
t: Theme
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -6,7 +6,9 @@ import type { SessionListItem, SessionListResponse } from '../gatewayTypes.js'
|
||||||
import { asRpcResult, rpcErrorMessage } from '../lib/rpc.js'
|
import { asRpcResult, rpcErrorMessage } from '../lib/rpc.js'
|
||||||
import type { Theme } from '../theme.js'
|
import type { Theme } from '../theme.js'
|
||||||
|
|
||||||
function age(ts: number): string {
|
const VISIBLE = 15
|
||||||
|
|
||||||
|
const age = (ts: number) => {
|
||||||
const d = (Date.now() / 1000 - ts) / 86400
|
const d = (Date.now() / 1000 - ts) / 86400
|
||||||
|
|
||||||
if (d < 1) {
|
if (d < 1) {
|
||||||
|
|
@ -20,19 +22,7 @@ function age(ts: number): string {
|
||||||
return `${Math.floor(d)}d ago`
|
return `${Math.floor(d)}d ago`
|
||||||
}
|
}
|
||||||
|
|
||||||
const VISIBLE = 15
|
export function SessionPicker({ gw, onCancel, onSelect, t }: SessionPickerProps) {
|
||||||
|
|
||||||
export function SessionPicker({
|
|
||||||
gw,
|
|
||||||
onCancel,
|
|
||||||
onSelect,
|
|
||||||
t
|
|
||||||
}: {
|
|
||||||
gw: GatewayClient
|
|
||||||
onCancel: () => void
|
|
||||||
onSelect: (id: string) => void
|
|
||||||
t: Theme
|
|
||||||
}) {
|
|
||||||
const [items, setItems] = useState<SessionListItem[]>([])
|
const [items, setItems] = useState<SessionListItem[]>([])
|
||||||
const [err, setErr] = useState('')
|
const [err, setErr] = useState('')
|
||||||
const [sel, setSel] = useState(0)
|
const [sel, setSel] = useState(0)
|
||||||
|
|
@ -107,36 +97,48 @@ export function SessionPicker({
|
||||||
}
|
}
|
||||||
|
|
||||||
const off = Math.max(0, Math.min(sel - Math.floor(VISIBLE / 2), items.length - VISIBLE))
|
const off = Math.max(0, Math.min(sel - Math.floor(VISIBLE / 2), items.length - VISIBLE))
|
||||||
const visible = items.slice(off, off + VISIBLE)
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Box flexDirection="column">
|
<Box flexDirection="column">
|
||||||
<Text bold color={t.color.amber}>
|
<Text bold color={t.color.amber}>
|
||||||
Resume Session
|
Resume Session
|
||||||
</Text>
|
</Text>
|
||||||
|
|
||||||
{off > 0 && <Text color={t.color.dim}> ↑ {off} more</Text>}
|
{off > 0 && <Text color={t.color.dim}> ↑ {off} more</Text>}
|
||||||
{visible.map((s, vi) => {
|
|
||||||
|
{items.slice(off, off + VISIBLE).map((s, vi) => {
|
||||||
const i = off + vi
|
const i = off + vi
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Box key={s.id}>
|
<Box key={s.id}>
|
||||||
<Text color={sel === i ? t.color.label : t.color.dim}>{sel === i ? '▸ ' : ' '}</Text>
|
<Text color={sel === i ? t.color.label : t.color.dim}>{sel === i ? '▸ ' : ' '}</Text>
|
||||||
|
|
||||||
<Box width={30}>
|
<Box width={30}>
|
||||||
<Text color={sel === i ? t.color.cornsilk : t.color.dim}>
|
<Text color={sel === i ? t.color.cornsilk : t.color.dim}>
|
||||||
{String(i + 1).padStart(2)}. [{s.id}]
|
{String(i + 1).padStart(2)}. [{s.id}]
|
||||||
</Text>
|
</Text>
|
||||||
</Box>
|
</Box>
|
||||||
|
|
||||||
<Box width={30}>
|
<Box width={30}>
|
||||||
<Text color={t.color.dim}>
|
<Text color={t.color.dim}>
|
||||||
({s.message_count} msgs, {age(s.started_at)}, {s.source || 'tui'})
|
({s.message_count} msgs, {age(s.started_at)}, {s.source || 'tui'})
|
||||||
</Text>
|
</Text>
|
||||||
</Box>
|
</Box>
|
||||||
|
|
||||||
<Text color={sel === i ? t.color.cornsilk : t.color.dim}>{s.title || s.preview || '(untitled)'}</Text>
|
<Text color={sel === i ? t.color.cornsilk : t.color.dim}>{s.title || s.preview || '(untitled)'}</Text>
|
||||||
</Box>
|
</Box>
|
||||||
)
|
)
|
||||||
})}
|
})}
|
||||||
|
|
||||||
{off + VISIBLE < items.length && <Text color={t.color.dim}> ↓ {items.length - off - VISIBLE} more</Text>}
|
{off + VISIBLE < items.length && <Text color={t.color.dim}> ↓ {items.length - off - VISIBLE} more</Text>}
|
||||||
<Text color={t.color.dim}>↑/↓ select · Enter resume · 1-9 quick · Esc cancel</Text>
|
<Text color={t.color.dim}>↑/↓ select · Enter resume · 1-9 quick · Esc cancel</Text>
|
||||||
</Box>
|
</Box>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface SessionPickerProps {
|
||||||
|
gw: GatewayClient
|
||||||
|
onCancel: () => void
|
||||||
|
onSelect: (id: string) => void
|
||||||
|
t: Theme
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -11,8 +11,6 @@ type InkExt = typeof Ink & {
|
||||||
const ink = Ink as unknown as InkExt
|
const ink = Ink as unknown as InkExt
|
||||||
const { Box, Text, useStdin, useInput, stringWidth, useDeclaredCursor, useTerminalFocus } = ink
|
const { Box, Text, useStdin, useInput, stringWidth, useDeclaredCursor, useTerminalFocus } = ink
|
||||||
|
|
||||||
// ── ANSI escapes ─────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
const ESC = '\x1b'
|
const ESC = '\x1b'
|
||||||
const INV = `${ESC}[7m`
|
const INV = `${ESC}[7m`
|
||||||
const INV_OFF = `${ESC}[27m`
|
const INV_OFF = `${ESC}[27m`
|
||||||
|
|
@ -25,8 +23,6 @@ const BRACKET_PASTE = new RegExp(`${ESC}?\\[20[01]~`, 'g')
|
||||||
const invert = (s: string) => INV + s + INV_OFF
|
const invert = (s: string) => INV + s + INV_OFF
|
||||||
const dim = (s: string) => DIM + s + DIM_OFF
|
const dim = (s: string) => DIM + s + DIM_OFF
|
||||||
|
|
||||||
// ── Grapheme segmenter (lazy singleton) ──────────────────────────────
|
|
||||||
|
|
||||||
let _seg: Intl.Segmenter | null = null
|
let _seg: Intl.Segmenter | null = null
|
||||||
const seg = () => (_seg ??= new Intl.Segmenter(undefined, { granularity: 'grapheme' }))
|
const seg = () => (_seg ??= new Intl.Segmenter(undefined, { granularity: 'grapheme' }))
|
||||||
const STOP_CACHE_MAX = 32
|
const STOP_CACHE_MAX = 32
|
||||||
|
|
@ -106,8 +102,6 @@ function nextPos(s: string, p: number) {
|
||||||
return s.length
|
return s.length
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Word movement ────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
function wordLeft(s: string, p: number) {
|
function wordLeft(s: string, p: number) {
|
||||||
let i = snapPos(s, p) - 1
|
let i = snapPos(s, p) - 1
|
||||||
|
|
||||||
|
|
@ -136,8 +130,6 @@ function wordRight(s: string, p: number) {
|
||||||
return i
|
return i
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Cursor layout (line/column from offset + terminal width) ─────────
|
|
||||||
|
|
||||||
function cursorLayout(value: string, cursor: number, cols: number) {
|
function cursorLayout(value: string, cursor: number, cols: number) {
|
||||||
const pos = Math.max(0, Math.min(cursor, value.length))
|
const pos = Math.max(0, Math.min(cursor, value.length))
|
||||||
const w = Math.max(1, cols - 1)
|
const w = Math.max(1, cols - 1)
|
||||||
|
|
@ -226,8 +218,6 @@ function offsetFromPosition(value: string, row: number, col: number, cols: numbe
|
||||||
return lastOffset
|
return lastOffset
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Render value with inverse-video cursor ───────────────────────────
|
|
||||||
|
|
||||||
function renderWithCursor(value: string, cursor: number) {
|
function renderWithCursor(value: string, cursor: number) {
|
||||||
const pos = Math.max(0, Math.min(cursor, value.length))
|
const pos = Math.max(0, Math.min(cursor, value.length))
|
||||||
|
|
||||||
|
|
@ -250,8 +240,6 @@ function renderWithCursor(value: string, cursor: number) {
|
||||||
return done ? out : out + invert(' ')
|
return done ? out : out + invert(' ')
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Forward-delete detection hook ────────────────────────────────────
|
|
||||||
|
|
||||||
function useFwdDelete(active: boolean) {
|
function useFwdDelete(active: boolean) {
|
||||||
const ref = useRef(false)
|
const ref = useRef(false)
|
||||||
const { inputEmitter: ee } = useStdin()
|
const { inputEmitter: ee } = useStdin()
|
||||||
|
|
@ -275,29 +263,6 @@ function useFwdDelete(active: boolean) {
|
||||||
return ref
|
return ref
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Types ────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
export interface PasteEvent {
|
|
||||||
bracketed?: boolean
|
|
||||||
cursor: number
|
|
||||||
hotkey?: boolean
|
|
||||||
text: string
|
|
||||||
value: string
|
|
||||||
}
|
|
||||||
|
|
||||||
interface Props {
|
|
||||||
columns?: number
|
|
||||||
value: string
|
|
||||||
onChange: (v: string) => void
|
|
||||||
onSubmit?: (v: string) => void
|
|
||||||
onPaste?: (e: PasteEvent) => { cursor: number; value: string } | null
|
|
||||||
mask?: string
|
|
||||||
placeholder?: string
|
|
||||||
focus?: boolean
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Component ────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
export function TextInput({
|
export function TextInput({
|
||||||
columns = 80,
|
columns = 80,
|
||||||
value,
|
value,
|
||||||
|
|
@ -307,7 +272,7 @@ export function TextInput({
|
||||||
mask,
|
mask,
|
||||||
placeholder = '',
|
placeholder = '',
|
||||||
focus = true
|
focus = true
|
||||||
}: Props) {
|
}: TextInputProps) {
|
||||||
const [cur, setCur] = useState(value.length)
|
const [cur, setCur] = useState(value.length)
|
||||||
const fwdDel = useFwdDelete(focus)
|
const fwdDel = useFwdDelete(focus)
|
||||||
const termFocus = useTerminalFocus()
|
const termFocus = useTerminalFocus()
|
||||||
|
|
@ -331,8 +296,6 @@ export function TextInput({
|
||||||
const raw = self.current ? vRef.current : value
|
const raw = self.current ? vRef.current : value
|
||||||
const display = mask ? raw.replace(/[^\n]/g, mask[0] ?? '*') : raw
|
const display = mask ? raw.replace(/[^\n]/g, mask[0] ?? '*') : raw
|
||||||
|
|
||||||
// ── Cursor declaration ───────────────────────────────────────────
|
|
||||||
|
|
||||||
const layout = useMemo(() => cursorLayout(display, cur, columns), [columns, cur, display])
|
const layout = useMemo(() => cursorLayout(display, cur, columns), [columns, cur, display])
|
||||||
|
|
||||||
const boxRef = useDeclaredCursor({
|
const boxRef = useDeclaredCursor({
|
||||||
|
|
@ -353,18 +316,6 @@ export function TextInput({
|
||||||
return renderWithCursor(display, cur)
|
return renderWithCursor(display, cur)
|
||||||
}, [cur, display, focus, placeholder])
|
}, [cur, display, focus, placeholder])
|
||||||
|
|
||||||
const clickCursor = (e: { localRow?: number; localCol?: number }) => {
|
|
||||||
if (!focus) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
const next = offsetFromPosition(display, e.localRow ?? 0, e.localCol ?? 0, columns)
|
|
||||||
setCur(next)
|
|
||||||
curRef.current = next
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Sync external value changes ──────────────────────────────────
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (self.current) {
|
if (self.current) {
|
||||||
self.current = false
|
self.current = false
|
||||||
|
|
@ -386,8 +337,6 @@ export function TextInput({
|
||||||
[]
|
[]
|
||||||
)
|
)
|
||||||
|
|
||||||
// ── Buffer ops (synchronous, ref-based) ──────────────────────────
|
|
||||||
|
|
||||||
const commit = (next: string, nextCur: number, track = true) => {
|
const commit = (next: string, nextCur: number, track = true) => {
|
||||||
const prev = vRef.current
|
const prev = vRef.current
|
||||||
const c = snapPos(next, nextCur)
|
const c = snapPos(next, nextCur)
|
||||||
|
|
@ -450,18 +399,14 @@ export function TextInput({
|
||||||
|
|
||||||
const ins = (v: string, c: number, s: string) => v.slice(0, c) + s + v.slice(c)
|
const ins = (v: string, c: number, s: string) => v.slice(0, c) + s + v.slice(c)
|
||||||
|
|
||||||
// ── Input handler ────────────────────────────────────────────────
|
|
||||||
|
|
||||||
useInput(
|
useInput(
|
||||||
(inp: string, k: Key, event: InputEvent) => {
|
(inp: string, k: Key, event: InputEvent) => {
|
||||||
const raw = event.keypress.raw
|
const eventRaw = event.keypress.raw
|
||||||
const metaPaste = raw === '\x1bv' || raw === '\x1bV'
|
|
||||||
|
|
||||||
if (metaPaste) {
|
if (eventRaw === '\x1bv' || eventRaw === '\x1bV') {
|
||||||
return void emitPaste({ cursor: curRef.current, hotkey: true, text: '', value: vRef.current })
|
return void emitPaste({ cursor: curRef.current, hotkey: true, text: '', value: vRef.current })
|
||||||
}
|
}
|
||||||
|
|
||||||
// Delegated to App
|
|
||||||
if (
|
if (
|
||||||
k.upArrow ||
|
k.upArrow ||
|
||||||
k.downArrow ||
|
k.downArrow ||
|
||||||
|
|
@ -487,7 +432,6 @@ export function TextInput({
|
||||||
let v = vRef.current
|
let v = vRef.current
|
||||||
const mod = k.ctrl || k.meta
|
const mod = k.ctrl || k.meta
|
||||||
|
|
||||||
// Undo / redo
|
|
||||||
if (k.ctrl && inp === 'z') {
|
if (k.ctrl && inp === 'z') {
|
||||||
return swap(undo, redo)
|
return swap(undo, redo)
|
||||||
}
|
}
|
||||||
|
|
@ -496,7 +440,6 @@ export function TextInput({
|
||||||
return swap(redo, undo)
|
return swap(redo, undo)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Navigation
|
|
||||||
if (k.home || (k.ctrl && inp === 'a')) {
|
if (k.home || (k.ctrl && inp === 'a')) {
|
||||||
c = 0
|
c = 0
|
||||||
} else if (k.end || (k.ctrl && inp === 'e')) {
|
} else if (k.end || (k.ctrl && inp === 'e')) {
|
||||||
|
|
@ -509,10 +452,7 @@ export function TextInput({
|
||||||
c = wordLeft(v, c)
|
c = wordLeft(v, c)
|
||||||
} else if (k.meta && inp === 'f') {
|
} else if (k.meta && inp === 'f') {
|
||||||
c = wordRight(v, c)
|
c = wordRight(v, c)
|
||||||
}
|
} else if ((k.backspace || k.delete) && !fwdDel.current && c > 0) {
|
||||||
|
|
||||||
// Deletion
|
|
||||||
else if ((k.backspace || k.delete) && !fwdDel.current && c > 0) {
|
|
||||||
if (mod) {
|
if (mod) {
|
||||||
const t = wordLeft(v, c)
|
const t = wordLeft(v, c)
|
||||||
v = v.slice(0, t) + v.slice(c)
|
v = v.slice(0, t) + v.slice(c)
|
||||||
|
|
@ -538,31 +478,28 @@ export function TextInput({
|
||||||
c = 0
|
c = 0
|
||||||
} else if (k.ctrl && inp === 'k') {
|
} else if (k.ctrl && inp === 'k') {
|
||||||
v = v.slice(0, c)
|
v = v.slice(0, c)
|
||||||
}
|
} else if (inp.length > 0) {
|
||||||
|
|
||||||
// Text insertion / paste buffering
|
|
||||||
else if (inp.length > 0) {
|
|
||||||
const bracketed = inp.includes('[200~')
|
const bracketed = inp.includes('[200~')
|
||||||
const raw = inp.replace(BRACKET_PASTE, '').replace(/\r\n/g, '\n').replace(/\r/g, '\n')
|
const text = inp.replace(BRACKET_PASTE, '').replace(/\r\n/g, '\n').replace(/\r/g, '\n')
|
||||||
|
|
||||||
if (bracketed && emitPaste({ bracketed: true, cursor: c, text: raw, value: v })) {
|
if (bracketed && emitPaste({ bracketed: true, cursor: c, text, value: v })) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!raw) {
|
if (!text) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
if (raw === '\n') {
|
if (text === '\n') {
|
||||||
return commit(ins(v, c, '\n'), c + 1)
|
return commit(ins(v, c, '\n'), c + 1)
|
||||||
}
|
}
|
||||||
|
|
||||||
if (raw.length > 1 || raw.includes('\n')) {
|
if (text.length > 1 || text.includes('\n')) {
|
||||||
if (!pasteBuf.current) {
|
if (!pasteBuf.current) {
|
||||||
pastePos.current = c
|
pastePos.current = c
|
||||||
}
|
}
|
||||||
|
|
||||||
pasteBuf.current += raw
|
pasteBuf.current += text
|
||||||
|
|
||||||
if (pasteTimer.current) {
|
if (pasteTimer.current) {
|
||||||
clearTimeout(pasteTimer.current)
|
clearTimeout(pasteTimer.current)
|
||||||
|
|
@ -573,9 +510,9 @@ export function TextInput({
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
if (PRINTABLE.test(raw)) {
|
if (PRINTABLE.test(text)) {
|
||||||
v = v.slice(0, c) + raw + v.slice(c)
|
v = v.slice(0, c) + text + v.slice(c)
|
||||||
c += raw.length
|
c += text.length
|
||||||
} else {
|
} else {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
@ -588,11 +525,39 @@ export function TextInput({
|
||||||
{ isActive: focus }
|
{ isActive: focus }
|
||||||
)
|
)
|
||||||
|
|
||||||
// ── Render ───────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Box onClick={clickCursor} ref={boxRef}>
|
<Box
|
||||||
|
onClick={(e: { localRow?: number; localCol?: number }) => {
|
||||||
|
if (!focus) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const next = offsetFromPosition(display, e.localRow ?? 0, e.localCol ?? 0, columns)
|
||||||
|
setCur(next)
|
||||||
|
curRef.current = next
|
||||||
|
}}
|
||||||
|
ref={boxRef}
|
||||||
|
>
|
||||||
<Text wrap="wrap">{rendered}</Text>
|
<Text wrap="wrap">{rendered}</Text>
|
||||||
</Box>
|
</Box>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface PasteEvent {
|
||||||
|
bracketed?: boolean
|
||||||
|
cursor: number
|
||||||
|
hotkey?: boolean
|
||||||
|
text: string
|
||||||
|
value: string
|
||||||
|
}
|
||||||
|
|
||||||
|
interface TextInputProps {
|
||||||
|
columns?: number
|
||||||
|
focus?: boolean
|
||||||
|
mask?: string
|
||||||
|
onChange: (v: string) => void
|
||||||
|
onPaste?: (e: PasteEvent) => { cursor: number; value: string } | null
|
||||||
|
onSubmit?: (v: string) => void
|
||||||
|
placeholder?: string
|
||||||
|
value: string
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -5,6 +5,16 @@ import type { ReactNode } from 'react'
|
||||||
import { $uiState } from '../app/uiStore.js'
|
import { $uiState } from '../app/uiStore.js'
|
||||||
import type { ThemeColors } from '../theme.js'
|
import type { ThemeColors } from '../theme.js'
|
||||||
|
|
||||||
|
export function Fg({ bold, c, children, dim, italic, literal, strikethrough, underline, wrap }: FgProps) {
|
||||||
|
const { theme } = useStore($uiState)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Text color={literal ?? (c && theme.color[c])} dimColor={dim} {...{ bold, italic, strikethrough, underline, wrap }}>
|
||||||
|
{children}
|
||||||
|
</Text>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
export type ThemeColor = keyof ThemeColors
|
export type ThemeColor = keyof ThemeColors
|
||||||
|
|
||||||
export interface FgProps {
|
export interface FgProps {
|
||||||
|
|
@ -18,28 +28,3 @@ export interface FgProps {
|
||||||
underline?: boolean
|
underline?: boolean
|
||||||
wrap?: 'end' | 'middle' | 'truncate' | 'truncate-end' | 'truncate-middle' | 'truncate-start' | 'wrap' | 'wrap-trim'
|
wrap?: 'end' | 'middle' | 'truncate' | 'truncate-end' | 'truncate-middle' | 'truncate-start' | 'wrap' | 'wrap-trim'
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Theme-aware text. `literal` wins; otherwise `c` is a palette key.
|
|
||||||
*
|
|
||||||
* <Fg c="amber">hi</Fg> // amber
|
|
||||||
* <Fg c="dim" dim>…</Fg> // dim cornsilk
|
|
||||||
* <Fg literal="#ff00ff">x</Fg> // raw hex
|
|
||||||
*/
|
|
||||||
export function Fg({ bold, c, children, dim, italic, literal, strikethrough, underline, wrap }: FgProps) {
|
|
||||||
const { theme } = useStore($uiState)
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Text
|
|
||||||
bold={bold}
|
|
||||||
color={literal ?? (c && theme.color[c])}
|
|
||||||
dimColor={dim}
|
|
||||||
italic={italic}
|
|
||||||
strikethrough={strikethrough}
|
|
||||||
underline={underline}
|
|
||||||
wrap={wrap}
|
|
||||||
>
|
|
||||||
{children}
|
|
||||||
</Text>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,2 @@
|
||||||
export const STARTUP_RESUME_ID = (process.env.HERMES_TUI_RESUME ?? '').trim()
|
export const STARTUP_RESUME_ID = (process.env.HERMES_TUI_RESUME ?? '').trim()
|
||||||
|
export const MOUSE_TRACKING = !/^(?:1|true|yes|on)$/i.test((process.env.HERMES_TUI_DISABLE_MOUSE ?? '').trim())
|
||||||
export const MOUSE_TRACKING = !/^(1|true|yes|on)$/.test(
|
|
||||||
(process.env.HERMES_TUI_DISABLE_MOUSE ?? '').trim().toLowerCase()
|
|
||||||
)
|
|
||||||
|
|
|
||||||
|
|
@ -11,30 +11,20 @@ const FORTUNES = [
|
||||||
'your instincts are correctly suspicious of that one branch'
|
'your instincts are correctly suspicious of that one branch'
|
||||||
]
|
]
|
||||||
|
|
||||||
const LEGENDARY_FORTUNES = [
|
const LEGENDARY = [
|
||||||
'legendary drop: one-line fix, first try',
|
'legendary drop: one-line fix, first try',
|
||||||
'legendary drop: every flaky test passes cleanly',
|
'legendary drop: every flaky test passes cleanly',
|
||||||
'legendary drop: your diff teaches by itself'
|
'legendary drop: your diff teaches by itself'
|
||||||
]
|
]
|
||||||
|
|
||||||
const hash = (input: string) => {
|
const hash = (s: string) => [...s].reduce((h, c) => Math.imul(h ^ c.charCodeAt(0), 16777619), 2166136261) >>> 0
|
||||||
let out = 2166136261
|
|
||||||
|
|
||||||
for (let i = 0; i < input.length; i++) {
|
const fromScore = (n: number) => {
|
||||||
out ^= input.charCodeAt(i)
|
const rare = n % 20 === 0
|
||||||
out = Math.imul(out, 16777619)
|
const bag = rare ? LEGENDARY : FORTUNES
|
||||||
}
|
|
||||||
|
|
||||||
return out >>> 0
|
return `${rare ? '🌟' : '🔮'} ${bag[n % bag.length]}`
|
||||||
}
|
|
||||||
|
|
||||||
const fromScore = (score: number) => {
|
|
||||||
const rare = score % 20 === 0
|
|
||||||
const bag = rare ? LEGENDARY_FORTUNES : FORTUNES
|
|
||||||
|
|
||||||
return `${rare ? '🌟' : '🔮'} ${bag[score % bag.length]}`
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export const randomFortune = () => fromScore(Math.floor(Math.random() * 0x7fffffff))
|
export const randomFortune = () => fromScore(Math.floor(Math.random() * 0x7fffffff))
|
||||||
|
|
||||||
export const dailyFortune = (seed: null | string) => fromScore(hash(`${seed || 'anon'}|${new Date().toDateString()}`))
|
export const dailyFortune = (seed: null | string) => fromScore(hash(`${seed || 'anon'}|${new Date().toDateString()}`))
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
import type { DetailsMode } from '../types.js'
|
import type { DetailsMode } from '../types.js'
|
||||||
|
|
||||||
const DETAILS_MODES: DetailsMode[] = ['hidden', 'collapsed', 'expanded']
|
const MODES = ['hidden', 'collapsed', 'expanded'] as const
|
||||||
|
|
||||||
const THINKING_FALLBACK: Record<string, DetailsMode> = {
|
const THINKING_FALLBACK: Record<string, DetailsMode> = {
|
||||||
collapsed: 'collapsed',
|
collapsed: 'collapsed',
|
||||||
|
|
@ -11,12 +11,10 @@ const THINKING_FALLBACK: Record<string, DetailsMode> = {
|
||||||
export const parseDetailsMode = (v: unknown): DetailsMode | null => {
|
export const parseDetailsMode = (v: unknown): DetailsMode | null => {
|
||||||
const s = typeof v === 'string' ? v.trim().toLowerCase() : ''
|
const s = typeof v === 'string' ? v.trim().toLowerCase() : ''
|
||||||
|
|
||||||
return DETAILS_MODES.includes(s as DetailsMode) ? (s as DetailsMode) : null
|
return MODES.find(m => m === s) ?? null
|
||||||
}
|
}
|
||||||
|
|
||||||
export const resolveDetailsMode = (
|
export const resolveDetailsMode = (d?: { details_mode?: unknown; thinking_mode?: unknown } | null): DetailsMode =>
|
||||||
d: { details_mode?: unknown; thinking_mode?: unknown } | null | undefined
|
|
||||||
): DetailsMode =>
|
|
||||||
parseDetailsMode(d?.details_mode) ??
|
parseDetailsMode(d?.details_mode) ??
|
||||||
THINKING_FALLBACK[
|
THINKING_FALLBACK[
|
||||||
String(d?.thinking_mode ?? '')
|
String(d?.thinking_mode ?? '')
|
||||||
|
|
@ -25,5 +23,4 @@ export const resolveDetailsMode = (
|
||||||
] ??
|
] ??
|
||||||
'collapsed'
|
'collapsed'
|
||||||
|
|
||||||
export const nextDetailsMode = (m: DetailsMode): DetailsMode =>
|
export const nextDetailsMode = (m: DetailsMode): DetailsMode => MODES[(MODES.indexOf(m) + 1) % MODES.length]!
|
||||||
DETAILS_MODES[(DETAILS_MODES.indexOf(m) + 1) % DETAILS_MODES.length]!
|
|
||||||
|
|
|
||||||
|
|
@ -2,30 +2,17 @@ import { LONG_MSG } from '../config/limits.js'
|
||||||
import { buildToolTrailLine, fmtK } from '../lib/text.js'
|
import { buildToolTrailLine, fmtK } from '../lib/text.js'
|
||||||
import type { Msg, SessionInfo } from '../types.js'
|
import type { Msg, SessionInfo } from '../types.js'
|
||||||
|
|
||||||
interface ImageMeta {
|
|
||||||
height?: number
|
|
||||||
token_estimate?: number
|
|
||||||
width?: number
|
|
||||||
}
|
|
||||||
|
|
||||||
interface TranscriptRow {
|
|
||||||
context?: string
|
|
||||||
name?: string
|
|
||||||
role?: string
|
|
||||||
text?: string
|
|
||||||
}
|
|
||||||
|
|
||||||
export const introMsg = (info: SessionInfo): Msg => ({ info, kind: 'intro', role: 'system', text: '' })
|
export const introMsg = (info: SessionInfo): Msg => ({ info, kind: 'intro', role: 'system', text: '' })
|
||||||
|
|
||||||
export const imageTokenMeta = (info: ImageMeta | null | undefined) =>
|
export const imageTokenMeta = (info?: ImageMeta | null) => {
|
||||||
[
|
const { width, height, token_estimate: t } = info ?? {}
|
||||||
info?.width && info.height ? `${info.width}x${info.height}` : '',
|
|
||||||
typeof info?.token_estimate === 'number' && info.token_estimate > 0 ? `~${fmtK(info.token_estimate)} tok` : ''
|
return [width && height ? `${width}x${height}` : '', (t ?? 0) > 0 ? `~${fmtK(t!)} tok` : '']
|
||||||
]
|
|
||||||
.filter(Boolean)
|
.filter(Boolean)
|
||||||
.join(' · ')
|
.join(' · ')
|
||||||
|
}
|
||||||
|
|
||||||
export const userDisplay = (text: string): string => {
|
export const userDisplay = (text: string) => {
|
||||||
if (text.length <= LONG_MSG) {
|
if (text.length <= LONG_MSG) {
|
||||||
return text
|
return text
|
||||||
}
|
}
|
||||||
|
|
@ -42,8 +29,8 @@ export const toTranscriptMessages = (rows: unknown): Msg[] => {
|
||||||
return []
|
return []
|
||||||
}
|
}
|
||||||
|
|
||||||
const result: Msg[] = []
|
const out: Msg[] = []
|
||||||
let pendingTools: string[] = []
|
let pending: string[] = []
|
||||||
|
|
||||||
for (const row of rows) {
|
for (const row of rows) {
|
||||||
if (!row || typeof row !== 'object') {
|
if (!row || typeof row !== 'object') {
|
||||||
|
|
@ -53,7 +40,7 @@ export const toTranscriptMessages = (rows: unknown): Msg[] => {
|
||||||
const { context, name, role, text } = row as TranscriptRow
|
const { context, name, role, text } = row as TranscriptRow
|
||||||
|
|
||||||
if (role === 'tool') {
|
if (role === 'tool') {
|
||||||
pendingTools.push(buildToolTrailLine(name ?? 'tool', context ?? ''))
|
pending.push(buildToolTrailLine(name ?? 'tool', context ?? ''))
|
||||||
|
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
@ -63,40 +50,35 @@ export const toTranscriptMessages = (rows: unknown): Msg[] => {
|
||||||
}
|
}
|
||||||
|
|
||||||
if (role === 'assistant') {
|
if (role === 'assistant') {
|
||||||
const msg: Msg = { role, text }
|
out.push({ role, text, ...(pending.length && { tools: pending }) })
|
||||||
|
pending = []
|
||||||
if (pendingTools.length) {
|
} else if (role === 'user' || role === 'system') {
|
||||||
msg.tools = pendingTools
|
out.push({ role, text })
|
||||||
pendingTools = []
|
pending = []
|
||||||
}
|
|
||||||
|
|
||||||
result.push(msg)
|
|
||||||
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
if (role === 'user' || role === 'system') {
|
|
||||||
pendingTools = []
|
|
||||||
result.push({ role, text })
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return result
|
return out
|
||||||
}
|
}
|
||||||
|
|
||||||
export function fmtDuration(ms: number) {
|
export const fmtDuration = (ms: number) => {
|
||||||
const total = Math.max(0, Math.floor(ms / 1000))
|
const t = Math.max(0, Math.floor(ms / 1000))
|
||||||
const hours = Math.floor(total / 3600)
|
const h = Math.floor(t / 3600)
|
||||||
const mins = Math.floor((total % 3600) / 60)
|
const m = Math.floor((t % 3600) / 60)
|
||||||
const secs = total % 60
|
const s = t % 60
|
||||||
|
|
||||||
if (hours > 0) {
|
return h > 0 ? `${h}h ${m}m` : m > 0 ? `${m}m ${s}s` : `${s}s`
|
||||||
return `${hours}h ${mins}m`
|
}
|
||||||
}
|
|
||||||
|
interface ImageMeta {
|
||||||
if (mins > 0) {
|
height?: number
|
||||||
return `${mins}m ${secs}s`
|
token_estimate?: number
|
||||||
}
|
width?: number
|
||||||
|
}
|
||||||
return `${secs}s`
|
|
||||||
|
interface TranscriptRow {
|
||||||
|
context?: string
|
||||||
|
name?: string
|
||||||
|
role?: string
|
||||||
|
text?: string
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,6 @@
|
||||||
export const shortCwd = (cwd: string, max = 28) => {
|
export const shortCwd = (cwd: string, max = 28) => {
|
||||||
const p = process.env.HOME && cwd.startsWith(process.env.HOME) ? `~${cwd.slice(process.env.HOME.length)}` : cwd
|
const h = process.env.HOME
|
||||||
|
const p = h && cwd.startsWith(h) ? `~${cwd.slice(h.length)}` : cwd
|
||||||
|
|
||||||
return p.length <= max ? p : `…${p.slice(-(max - 1))}`
|
return p.length <= max ? p : `…${p.slice(-(max - 1))}`
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,25 +1,7 @@
|
||||||
export interface ParsedSlashCommand {
|
export const looksLikeSlashCommand = (text: string) => /^\/[^\s/]*(?:\s|$)/.test(text)
|
||||||
arg: string
|
|
||||||
cmd: string
|
export const parseSlashCommand = (cmd: string) => {
|
||||||
name: string
|
const [name = '', ...rest] = cmd.slice(1).split(/\s+/)
|
||||||
}
|
|
||||||
|
return { arg: rest.join(' '), cmd, name: name.toLowerCase() }
|
||||||
export const looksLikeSlashCommand = (text: string) => {
|
|
||||||
if (!text.startsWith('/')) {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
|
|
||||||
const first = text.split(/\s+/, 1)[0] || ''
|
|
||||||
|
|
||||||
return !first.slice(1).includes('/')
|
|
||||||
}
|
|
||||||
|
|
||||||
export const parseSlashCommand = (cmd: string): ParsedSlashCommand => {
|
|
||||||
const [rawName = '', ...rest] = cmd.slice(1).split(/\s+/)
|
|
||||||
|
|
||||||
return {
|
|
||||||
arg: rest.join(' '),
|
|
||||||
cmd,
|
|
||||||
name: rawName.toLowerCase()
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -26,17 +26,13 @@ export const stickyPromptFromViewport = (
|
||||||
}
|
}
|
||||||
|
|
||||||
const first = Math.max(0, Math.min(messages.length - 1, upperBound(offsets, top) - 1))
|
const first = Math.max(0, Math.min(messages.length - 1, upperBound(offsets, top) - 1))
|
||||||
const aboveViewport = (i: number) => (offsets[i] ?? 0) + 1 < top
|
|
||||||
|
|
||||||
// Walk backward from the first visible row. The nearest user message wins:
|
|
||||||
// if it's still on screen, no sticky is needed; if it's already scrolled
|
|
||||||
// above the top, its text becomes the floating breadcrumb.
|
|
||||||
for (let i = first; i >= 0; i--) {
|
for (let i = first; i >= 0; i--) {
|
||||||
if (messages[i]?.role !== 'user') {
|
if (messages[i]?.role !== 'user') {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
return aboveViewport(i) ? userDisplay(messages[i]!.text.trim()).replace(/\s+/g, ' ').trim() : ''
|
return (offsets[i] ?? 0) + 1 < top ? userDisplay(messages[i]!.text.trim()).replace(/\s+/g, ' ').trim() : ''
|
||||||
}
|
}
|
||||||
|
|
||||||
return ''
|
return ''
|
||||||
|
|
|
||||||
|
|
@ -12,49 +12,34 @@ const STARTUP_TIMEOUT_MS = Math.max(5000, parseInt(process.env.HERMES_TUI_STARTU
|
||||||
const REQUEST_TIMEOUT_MS = Math.max(30000, parseInt(process.env.HERMES_TUI_RPC_TIMEOUT_MS ?? '120000', 10) || 120000)
|
const REQUEST_TIMEOUT_MS = Math.max(30000, parseInt(process.env.HERMES_TUI_RPC_TIMEOUT_MS ?? '120000', 10) || 120000)
|
||||||
|
|
||||||
const resolvePython = (root: string) => {
|
const resolvePython = (root: string) => {
|
||||||
const configured = process.env.HERMES_PYTHON?.trim()
|
const configured = process.env.HERMES_PYTHON?.trim() || process.env.PYTHON?.trim()
|
||||||
|
|
||||||
if (configured) {
|
if (configured) {
|
||||||
return configured
|
return configured
|
||||||
}
|
}
|
||||||
|
|
||||||
const envPython = process.env.PYTHON?.trim()
|
|
||||||
|
|
||||||
if (envPython) {
|
|
||||||
return envPython
|
|
||||||
}
|
|
||||||
|
|
||||||
const venv = process.env.VIRTUAL_ENV?.trim()
|
const venv = process.env.VIRTUAL_ENV?.trim()
|
||||||
|
|
||||||
const candidates = [
|
const hit = [
|
||||||
venv ? resolve(venv, 'bin/python') : '',
|
venv && resolve(venv, 'bin/python'),
|
||||||
venv ? resolve(venv, 'Scripts/python.exe') : '',
|
venv && resolve(venv, 'Scripts/python.exe'),
|
||||||
resolve(root, '.venv/bin/python'),
|
resolve(root, '.venv/bin/python'),
|
||||||
resolve(root, '.venv/bin/python3'),
|
resolve(root, '.venv/bin/python3'),
|
||||||
resolve(root, 'venv/bin/python'),
|
resolve(root, 'venv/bin/python'),
|
||||||
resolve(root, 'venv/bin/python3')
|
resolve(root, 'venv/bin/python3')
|
||||||
].filter(Boolean)
|
].find(p => p && existsSync(p))
|
||||||
|
|
||||||
const hit = candidates.find(path => existsSync(path))
|
return hit || (process.platform === 'win32' ? 'python' : 'python3')
|
||||||
|
|
||||||
if (hit) {
|
|
||||||
return hit
|
|
||||||
}
|
|
||||||
|
|
||||||
return process.platform === 'win32' ? 'python' : 'python3'
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const asGatewayEvent = (value: unknown): GatewayEvent | null => {
|
const asGatewayEvent = (value: unknown): GatewayEvent | null =>
|
||||||
if (!value || typeof value !== 'object' || Array.isArray(value)) {
|
value && typeof value === 'object' && !Array.isArray(value) && typeof (value as { type?: unknown }).type === 'string'
|
||||||
return null
|
? (value as GatewayEvent)
|
||||||
}
|
: null
|
||||||
|
|
||||||
return typeof (value as { type?: unknown }).type === 'string' ? (value as GatewayEvent) : null
|
|
||||||
}
|
|
||||||
|
|
||||||
interface Pending {
|
interface Pending {
|
||||||
resolve: (v: unknown) => void
|
|
||||||
reject: (e: Error) => void
|
reject: (e: Error) => void
|
||||||
|
resolve: (v: unknown) => void
|
||||||
}
|
}
|
||||||
|
|
||||||
export class GatewayClient extends EventEmitter {
|
export class GatewayClient extends EventEmitter {
|
||||||
|
|
@ -81,9 +66,7 @@ export class GatewayClient extends EventEmitter {
|
||||||
}
|
}
|
||||||
|
|
||||||
if (this.subscribed) {
|
if (this.subscribed) {
|
||||||
this.emit('event', ev)
|
return void this.emit('event', ev)
|
||||||
|
|
||||||
return
|
|
||||||
}
|
}
|
||||||
|
|
||||||
this.bufferedEvents.push(ev)
|
this.bufferedEvents.push(ev)
|
||||||
|
|
@ -94,8 +77,9 @@ export class GatewayClient extends EventEmitter {
|
||||||
const python = resolvePython(root)
|
const python = resolvePython(root)
|
||||||
const cwd = process.env.HERMES_CWD || root
|
const cwd = process.env.HERMES_CWD || root
|
||||||
const env = { ...process.env }
|
const env = { ...process.env }
|
||||||
const pyPath = (env.PYTHONPATH ?? '').trim()
|
const pyPath = env.PYTHONPATH?.trim()
|
||||||
env.PYTHONPATH = pyPath ? `${root}${delimiter}${pyPath}` : root
|
env.PYTHONPATH = pyPath ? `${root}${delimiter}${pyPath}` : root
|
||||||
|
|
||||||
this.ready = false
|
this.ready = false
|
||||||
this.bufferedEvents = []
|
this.bufferedEvents = []
|
||||||
this.pendingExit = undefined
|
this.pendingExit = undefined
|
||||||
|
|
@ -121,11 +105,7 @@ export class GatewayClient extends EventEmitter {
|
||||||
this.publish({ type: 'gateway.start_timeout', payload: { cwd, python } })
|
this.publish({ type: 'gateway.start_timeout', payload: { cwd, python } })
|
||||||
}, STARTUP_TIMEOUT_MS)
|
}, STARTUP_TIMEOUT_MS)
|
||||||
|
|
||||||
this.proc = spawn(python, ['-m', 'tui_gateway.entry'], {
|
this.proc = spawn(python, ['-m', 'tui_gateway.entry'], { cwd, env, stdio: ['pipe', 'pipe', 'pipe'] })
|
||||||
cwd,
|
|
||||||
env,
|
|
||||||
stdio: ['pipe', 'pipe', 'pipe']
|
|
||||||
})
|
|
||||||
|
|
||||||
this.stdoutRl = createInterface({ input: this.proc.stdout! })
|
this.stdoutRl = createInterface({ input: this.proc.stdout! })
|
||||||
this.stdoutRl.on('line', raw => {
|
this.stdoutRl.on('line', raw => {
|
||||||
|
|
@ -133,8 +113,9 @@ export class GatewayClient extends EventEmitter {
|
||||||
this.dispatch(JSON.parse(raw))
|
this.dispatch(JSON.parse(raw))
|
||||||
} catch {
|
} catch {
|
||||||
const preview = raw.trim().slice(0, MAX_LOG_PREVIEW) || '(empty line)'
|
const preview = raw.trim().slice(0, MAX_LOG_PREVIEW) || '(empty line)'
|
||||||
|
|
||||||
this.pushLog(`[protocol] malformed stdout: ${preview}`)
|
this.pushLog(`[protocol] malformed stdout: ${preview}`)
|
||||||
this.publish({ type: 'gateway.protocol_error', payload: { preview } } satisfies GatewayEvent)
|
this.publish({ type: 'gateway.protocol_error', payload: { preview } })
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|
@ -147,13 +128,13 @@ export class GatewayClient extends EventEmitter {
|
||||||
}
|
}
|
||||||
|
|
||||||
this.pushLog(line)
|
this.pushLog(line)
|
||||||
this.publish({ type: 'gateway.stderr', payload: { line } } satisfies GatewayEvent)
|
this.publish({ type: 'gateway.stderr', payload: { line } })
|
||||||
})
|
})
|
||||||
|
|
||||||
this.proc.on('error', err => {
|
this.proc.on('error', err => {
|
||||||
this.pushLog(`[spawn] ${err.message}`)
|
this.pushLog(`[spawn] ${err.message}`)
|
||||||
this.rejectPending(new Error(`gateway error: ${err.message}`))
|
this.rejectPending(new Error(`gateway error: ${err.message}`))
|
||||||
this.publish({ type: 'gateway.stderr', payload: { line: `[spawn] ${err.message}` } } satisfies GatewayEvent)
|
this.publish({ type: 'gateway.stderr', payload: { line: `[spawn] ${err.message}` } })
|
||||||
})
|
})
|
||||||
|
|
||||||
this.proc.on('exit', code => {
|
this.proc.on('exit', code => {
|
||||||
|
|
@ -181,6 +162,7 @@ export class GatewayClient extends EventEmitter {
|
||||||
|
|
||||||
if (msg.error) {
|
if (msg.error) {
|
||||||
const err = msg.error as { message?: unknown } | null | undefined
|
const err = msg.error as { message?: unknown } | null | undefined
|
||||||
|
|
||||||
p.reject(new Error(typeof err?.message === 'string' ? err.message : 'request failed'))
|
p.reject(new Error(typeof err?.message === 'string' ? err.message : 'request failed'))
|
||||||
} else {
|
} else {
|
||||||
p.resolve(msg.result)
|
p.resolve(msg.result)
|
||||||
|
|
@ -199,30 +181,29 @@ export class GatewayClient extends EventEmitter {
|
||||||
}
|
}
|
||||||
|
|
||||||
private pushLog(line: string) {
|
private pushLog(line: string) {
|
||||||
this.logs.push(line)
|
if (this.logs.push(line) > MAX_GATEWAY_LOG_LINES) {
|
||||||
|
|
||||||
if (this.logs.length > MAX_GATEWAY_LOG_LINES) {
|
|
||||||
this.logs.splice(0, this.logs.length - MAX_GATEWAY_LOG_LINES)
|
this.logs.splice(0, this.logs.length - MAX_GATEWAY_LOG_LINES)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private rejectPending(err: Error) {
|
private rejectPending(err: Error) {
|
||||||
for (const [id, pending] of this.pending) {
|
for (const p of this.pending.values()) {
|
||||||
this.pending.delete(id)
|
p.reject(err)
|
||||||
pending.reject(err)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
this.pending.clear()
|
||||||
}
|
}
|
||||||
|
|
||||||
drain() {
|
drain() {
|
||||||
this.subscribed = true
|
this.subscribed = true
|
||||||
const pending = this.bufferedEvents.splice(0)
|
|
||||||
|
|
||||||
for (const ev of pending) {
|
for (const ev of this.bufferedEvents.splice(0)) {
|
||||||
this.emit('event', ev)
|
this.emit('event', ev)
|
||||||
}
|
}
|
||||||
|
|
||||||
if (this.pendingExit !== undefined) {
|
if (this.pendingExit !== undefined) {
|
||||||
const code = this.pendingExit
|
const code = this.pendingExit
|
||||||
|
|
||||||
this.pendingExit = undefined
|
this.pendingExit = undefined
|
||||||
this.emit('exit', code)
|
this.emit('exit', code)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -34,7 +34,7 @@ export function useCompletion(input: string, blocked: boolean, gw: GatewayClient
|
||||||
ref.current = input
|
ref.current = input
|
||||||
|
|
||||||
const isSlash = input.startsWith('/')
|
const isSlash = input.startsWith('/')
|
||||||
const pathWord = !isSlash ? (input.match(TAB_PATH_RE)?.[1] ?? null) : null
|
const pathWord = isSlash ? null : (input.match(TAB_PATH_RE)?.[1] ?? null)
|
||||||
|
|
||||||
if (!isSlash && !pathWord) {
|
if (!isSlash && !pathWord) {
|
||||||
clear()
|
clear()
|
||||||
|
|
@ -42,6 +42,8 @@ export function useCompletion(input: string, blocked: boolean, gw: GatewayClient
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const pathReplace = input.length - (pathWord?.length ?? 0)
|
||||||
|
|
||||||
const t = setTimeout(() => {
|
const t = setTimeout(() => {
|
||||||
if (ref.current !== input) {
|
if (ref.current !== input) {
|
||||||
return
|
return
|
||||||
|
|
@ -53,15 +55,15 @@ export function useCompletion(input: string, blocked: boolean, gw: GatewayClient
|
||||||
|
|
||||||
req
|
req
|
||||||
.then(raw => {
|
.then(raw => {
|
||||||
const r = asRpcResult<CompletionResponse>(raw)
|
|
||||||
|
|
||||||
if (ref.current !== input) {
|
if (ref.current !== input) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const r = asRpcResult<CompletionResponse>(raw)
|
||||||
|
|
||||||
setCompletions(r?.items ?? [])
|
setCompletions(r?.items ?? [])
|
||||||
setCompIdx(0)
|
setCompIdx(0)
|
||||||
setCompReplace(isSlash ? (r?.replace_from ?? 1) : input.length - (pathWord?.length ?? 0))
|
setCompReplace(isSlash ? (r?.replace_from ?? 1) : pathReplace)
|
||||||
})
|
})
|
||||||
.catch((e: unknown) => {
|
.catch((e: unknown) => {
|
||||||
if (ref.current !== input) {
|
if (ref.current !== input) {
|
||||||
|
|
@ -76,7 +78,7 @@ export function useCompletion(input: string, blocked: boolean, gw: GatewayClient
|
||||||
}
|
}
|
||||||
])
|
])
|
||||||
setCompIdx(0)
|
setCompIdx(0)
|
||||||
setCompReplace(isSlash ? 1 : input.length - (pathWord?.length ?? 0))
|
setCompReplace(isSlash ? 1 : pathReplace)
|
||||||
})
|
})
|
||||||
}, 60)
|
}, 60)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
import { useCallback, useRef, useState } from 'react'
|
import { useRef, useState } from 'react'
|
||||||
|
|
||||||
import * as inputHistory from '../lib/history.js'
|
import * as inputHistory from '../lib/history.js'
|
||||||
|
|
||||||
|
|
@ -7,9 +7,5 @@ export function useInputHistory() {
|
||||||
const [historyIdx, setHistoryIdx] = useState<number | null>(null)
|
const [historyIdx, setHistoryIdx] = useState<number | null>(null)
|
||||||
const historyDraftRef = useRef('')
|
const historyDraftRef = useRef('')
|
||||||
|
|
||||||
const pushHistory = useCallback((text: string) => {
|
return { historyRef, historyIdx, setHistoryIdx, historyDraftRef, pushHistory: inputHistory.append }
|
||||||
inputHistory.append(text)
|
|
||||||
}, [])
|
|
||||||
|
|
||||||
return { historyRef, historyIdx, setHistoryIdx, historyDraftRef, pushHistory }
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -6,9 +6,7 @@ export function useQueue() {
|
||||||
const queueEditRef = useRef<number | null>(null)
|
const queueEditRef = useRef<number | null>(null)
|
||||||
const [queueEditIdx, setQueueEditIdx] = useState<number | null>(null)
|
const [queueEditIdx, setQueueEditIdx] = useState<number | null>(null)
|
||||||
|
|
||||||
const syncQueue = useCallback(() => {
|
const syncQueue = useCallback(() => setQueuedDisplay([...queueRef.current]), [])
|
||||||
setQueuedDisplay([...queueRef.current])
|
|
||||||
}, [])
|
|
||||||
|
|
||||||
const setQueueEdit = useCallback((idx: number | null) => {
|
const setQueueEdit = useCallback((idx: number | null) => {
|
||||||
queueEditRef.current = idx
|
queueEditRef.current = idx
|
||||||
|
|
@ -39,12 +37,12 @@ export function useQueue() {
|
||||||
)
|
)
|
||||||
|
|
||||||
return {
|
return {
|
||||||
queueRef,
|
|
||||||
queueEditRef,
|
|
||||||
queuedDisplay,
|
|
||||||
queueEditIdx,
|
|
||||||
enqueue,
|
|
||||||
dequeue,
|
dequeue,
|
||||||
|
enqueue,
|
||||||
|
queueEditIdx,
|
||||||
|
queueEditRef,
|
||||||
|
queueRef,
|
||||||
|
queuedDisplay,
|
||||||
replaceQ,
|
replaceQ,
|
||||||
setQueueEdit,
|
setQueueEdit,
|
||||||
syncQueue
|
syncQueue
|
||||||
|
|
|
||||||
|
|
@ -33,9 +33,9 @@ export function useVirtualHistory(
|
||||||
items: readonly { key: string }[],
|
items: readonly { key: string }[],
|
||||||
{ estimate = ESTIMATE, overscan = OVERSCAN, maxMounted = MAX_MOUNTED, coldStartCount = COLD_START } = {}
|
{ estimate = ESTIMATE, overscan = OVERSCAN, maxMounted = MAX_MOUNTED, coldStartCount = COLD_START } = {}
|
||||||
) {
|
) {
|
||||||
const nodes = useRef(new Map<string, any>())
|
const nodes = useRef(new Map<string, unknown>())
|
||||||
const heights = useRef(new Map<string, number>())
|
const heights = useRef(new Map<string, number>())
|
||||||
const refs = useRef(new Map<string, (el: any) => void>())
|
const refs = useRef(new Map<string, (el: unknown) => void>())
|
||||||
const [ver, setVer] = useState(0)
|
const [ver, setVer] = useState(0)
|
||||||
|
|
||||||
useSyncExternalStore(
|
useSyncExternalStore(
|
||||||
|
|
@ -108,7 +108,7 @@ export function useVirtualHistory(
|
||||||
let fn = refs.current.get(key)
|
let fn = refs.current.get(key)
|
||||||
|
|
||||||
if (!fn) {
|
if (!fn) {
|
||||||
fn = (el: any) => (el ? nodes.current.set(key, el) : nodes.current.delete(key))
|
fn = (el: unknown) => (el ? nodes.current.set(key, el) : nodes.current.delete(key))
|
||||||
refs.current.set(key, fn)
|
refs.current.set(key, fn)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -125,7 +125,7 @@ export function useVirtualHistory(
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
const h = Math.ceil(nodes.current.get(k)?.yogaNode?.getComputedHeight?.() ?? 0)
|
const h = Math.ceil((nodes.current.get(k) as MeasuredNode | undefined)?.yogaNode?.getComputedHeight?.() ?? 0)
|
||||||
|
|
||||||
if (h > 0 && heights.current.get(k) !== h) {
|
if (h > 0 && heights.current.get(k) !== h) {
|
||||||
heights.current.set(k, h)
|
heights.current.set(k, h)
|
||||||
|
|
@ -139,11 +139,15 @@ export function useVirtualHistory(
|
||||||
}, [end, items, start])
|
}, [end, items, start])
|
||||||
|
|
||||||
return {
|
return {
|
||||||
start,
|
|
||||||
end,
|
|
||||||
offsets,
|
|
||||||
topSpacer: offsets[start] ?? 0,
|
|
||||||
bottomSpacer: Math.max(0, total - (offsets[end] ?? total)),
|
bottomSpacer: Math.max(0, total - (offsets[end] ?? total)),
|
||||||
measureRef
|
end,
|
||||||
|
measureRef,
|
||||||
|
offsets,
|
||||||
|
start,
|
||||||
|
topSpacer: offsets[start] ?? 0
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface MeasuredNode {
|
||||||
|
yogaNode?: { getComputedHeight?: () => number } | null
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -3,12 +3,12 @@ import { homedir } from 'node:os'
|
||||||
import { join } from 'node:path'
|
import { join } from 'node:path'
|
||||||
|
|
||||||
const MAX = 1000
|
const MAX = 1000
|
||||||
const dir = join(process.env.HERMES_HOME ?? join(homedir(), '.hermes'))
|
const dir = process.env.HERMES_HOME ?? join(homedir(), '.hermes')
|
||||||
const file = join(dir, '.hermes_history')
|
const file = join(dir, '.hermes_history')
|
||||||
|
|
||||||
let cache: string[] | null = null
|
let cache: string[] | null = null
|
||||||
|
|
||||||
export function load(): string[] {
|
export function load() {
|
||||||
if (cache) {
|
if (cache) {
|
||||||
return cache
|
return cache
|
||||||
}
|
}
|
||||||
|
|
@ -20,11 +20,10 @@ export function load(): string[] {
|
||||||
return cache
|
return cache
|
||||||
}
|
}
|
||||||
|
|
||||||
const lines = readFileSync(file, 'utf8').split('\n')
|
|
||||||
const entries: string[] = []
|
const entries: string[] = []
|
||||||
let current: string[] = []
|
let current: string[] = []
|
||||||
|
|
||||||
for (const line of lines) {
|
for (const line of readFileSync(file, 'utf8').split('\n')) {
|
||||||
if (line.startsWith('+')) {
|
if (line.startsWith('+')) {
|
||||||
current.push(line.slice(1))
|
current.push(line.slice(1))
|
||||||
} else if (current.length) {
|
} else if (current.length) {
|
||||||
|
|
@ -45,7 +44,7 @@ export function load(): string[] {
|
||||||
return cache
|
return cache
|
||||||
}
|
}
|
||||||
|
|
||||||
export function append(line: string): void {
|
export function append(line: string) {
|
||||||
const trimmed = line.trim()
|
const trimmed = line.trim()
|
||||||
|
|
||||||
if (!trimmed) {
|
if (!trimmed) {
|
||||||
|
|
@ -73,11 +72,11 @@ export function append(line: string): void {
|
||||||
|
|
||||||
const encoded = trimmed
|
const encoded = trimmed
|
||||||
.split('\n')
|
.split('\n')
|
||||||
.map(l => '+' + l)
|
.map(l => `+${l}`)
|
||||||
.join('\n')
|
.join('\n')
|
||||||
|
|
||||||
appendFileSync(file, `\n# ${ts}\n${encoded}\n`)
|
appendFileSync(file, `\n# ${ts}\n${encoded}\n`)
|
||||||
} catch {
|
} catch {
|
||||||
/* ignore */
|
void 0
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,4 @@
|
||||||
import type { Msg, Role } from '../types.js'
|
import type { Msg, Role } from '../types.js'
|
||||||
|
|
||||||
export function upsert(prev: Msg[], role: Role, text: string): Msg[] {
|
export const upsert = (prev: Msg[], role: Role, text: string): Msg[] =>
|
||||||
return prev.at(-1)?.role === role ? [...prev.slice(0, -1), { role, text }] : [...prev, { role, text }]
|
prev.at(-1)?.role === role ? [...prev.slice(0, -1), { role, text }] : [...prev, { role, text }]
|
||||||
}
|
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,2 @@
|
||||||
export function writeOsc52Clipboard(s: string): void {
|
export const writeOsc52Clipboard = (s: string) =>
|
||||||
process.stdout.write('\x1b]52;c;' + Buffer.from(s, 'utf8').toString('base64') + '\x07')
|
process.stdout.write(`\x1b]52;c;${Buffer.from(s, 'utf8').toString('base64')}\x07`)
|
||||||
}
|
|
||||||
|
|
|
||||||
|
|
@ -2,13 +2,8 @@ import type { CommandDispatchResponse } from '../gatewayTypes.js'
|
||||||
|
|
||||||
export type RpcResult = Record<string, any>
|
export type RpcResult = Record<string, any>
|
||||||
|
|
||||||
export const asRpcResult = <T extends RpcResult = RpcResult>(value: unknown): T | null => {
|
export const asRpcResult = <T extends RpcResult = RpcResult>(value: unknown): T | null =>
|
||||||
if (!value || typeof value !== 'object' || Array.isArray(value)) {
|
!value || typeof value !== 'object' || Array.isArray(value) ? null : (value as T)
|
||||||
return null
|
|
||||||
}
|
|
||||||
|
|
||||||
return value as T
|
|
||||||
}
|
|
||||||
|
|
||||||
export const asCommandDispatch = (value: unknown): CommandDispatchResponse | null => {
|
export const asCommandDispatch = (value: unknown): CommandDispatchResponse | null => {
|
||||||
const o = asRpcResult(value)
|
const o = asRpcResult(value)
|
||||||
|
|
@ -28,24 +23,11 @@ export const asCommandDispatch = (value: unknown): CommandDispatchResponse | nul
|
||||||
}
|
}
|
||||||
|
|
||||||
if (t === 'skill' && typeof o.name === 'string') {
|
if (t === 'skill' && typeof o.name === 'string') {
|
||||||
return {
|
return { type: 'skill', name: o.name, message: typeof o.message === 'string' ? o.message : undefined }
|
||||||
type: 'skill',
|
|
||||||
name: o.name,
|
|
||||||
message: typeof o.message === 'string' ? o.message : undefined
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
|
|
||||||
export const rpcErrorMessage = (err: unknown) => {
|
export const rpcErrorMessage = (err: unknown) =>
|
||||||
if (err instanceof Error && err.message) {
|
err instanceof Error && err.message ? err.message : typeof err === 'string' && err.trim() ? err : 'request failed'
|
||||||
return err.message
|
|
||||||
}
|
|
||||||
|
|
||||||
if (typeof err === 'string' && err.trim()) {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
return 'request failed'
|
|
||||||
}
|
|
||||||
|
|
|
||||||
|
|
@ -1,12 +1,13 @@
|
||||||
import { THINKING_COT_MAX } from '../config/limits.js'
|
import { THINKING_COT_MAX } from '../config/limits.js'
|
||||||
import type { ThinkingMode } from '../types.js'
|
import type { ThinkingMode } from '../types.js'
|
||||||
|
|
||||||
// eslint-disable-next-line no-control-regex
|
const ESC = String.fromCharCode(27)
|
||||||
const ANSI_RE = /\x1b\[[0-9;]*m/g
|
const ANSI_RE = new RegExp(`${ESC}\\[[0-9;]*m`, 'g')
|
||||||
|
const WS_RE = /\s+/g
|
||||||
|
|
||||||
export const stripAnsi = (s: string) => s.replace(ANSI_RE, '')
|
export const stripAnsi = (s: string) => s.replace(ANSI_RE, '')
|
||||||
|
|
||||||
export const hasAnsi = (s: string) => s.includes('\x1b[') || s.includes('\x1b]')
|
export const hasAnsi = (s: string) => s.includes(`${ESC}[`) || s.includes(`${ESC}]`)
|
||||||
|
|
||||||
const renderEstimateLine = (line: string) => {
|
const renderEstimateLine = (line: string) => {
|
||||||
const trimmed = line.trim()
|
const trimmed = line.trim()
|
||||||
|
|
@ -38,7 +39,7 @@ const renderEstimateLine = (line: string) => {
|
||||||
}
|
}
|
||||||
|
|
||||||
export const compactPreview = (s: string, max: number) => {
|
export const compactPreview = (s: string, max: number) => {
|
||||||
const one = s.replace(/\s+/g, ' ').trim()
|
const one = s.replace(WS_RE, ' ').trim()
|
||||||
|
|
||||||
return !one ? '' : one.length > max ? one.slice(0, max - 1) + '…' : one
|
return !one ? '' : one.length > max ? one.slice(0, max - 1) + '…' : one
|
||||||
}
|
}
|
||||||
|
|
@ -46,17 +47,13 @@ export const compactPreview = (s: string, max: number) => {
|
||||||
export const estimateTokensRough = (text: string) => (!text ? 0 : (text.length + 3) >> 2)
|
export const estimateTokensRough = (text: string) => (!text ? 0 : (text.length + 3) >> 2)
|
||||||
|
|
||||||
export const edgePreview = (s: string, head = 16, tail = 28) => {
|
export const edgePreview = (s: string, head = 16, tail = 28) => {
|
||||||
const one = s.replace(/\s+/g, ' ').trim().replace(/\]\]/g, '] ]')
|
const one = s.replace(WS_RE, ' ').trim().replace(/\]\]/g, '] ]')
|
||||||
|
|
||||||
if (!one) {
|
return !one
|
||||||
return ''
|
? ''
|
||||||
}
|
: one.length <= head + tail + 4
|
||||||
|
? one
|
||||||
if (one.length <= head + tail + 4) {
|
: `${one.slice(0, head).trimEnd()}.. ${one.slice(-tail).trimStart()}`
|
||||||
return one
|
|
||||||
}
|
|
||||||
|
|
||||||
return `${one.slice(0, head).trimEnd()}.. ${one.slice(-tail).trimStart()}`
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export const pasteTokenLabel = (text: string, lineCount: number) => {
|
export const pasteTokenLabel = (text: string, lineCount: number) => {
|
||||||
|
|
@ -76,15 +73,7 @@ export const pasteTokenLabel = (text: string, lineCount: number) => {
|
||||||
export const thinkingPreview = (reasoning: string, mode: ThinkingMode, max: number = THINKING_COT_MAX) => {
|
export const thinkingPreview = (reasoning: string, mode: ThinkingMode, max: number = THINKING_COT_MAX) => {
|
||||||
const raw = reasoning.trim()
|
const raw = reasoning.trim()
|
||||||
|
|
||||||
if (!raw || mode === 'collapsed') {
|
return !raw || mode === 'collapsed' ? '' : mode === 'full' ? raw : compactPreview(raw.replace(WS_RE, ' '), max)
|
||||||
return ''
|
|
||||||
}
|
|
||||||
|
|
||||||
if (mode === 'full') {
|
|
||||||
return raw
|
|
||||||
}
|
|
||||||
|
|
||||||
return compactPreview(raw.replace(/\s+/g, ' '), max)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export const stripTrailingPasteNewlines = (text: string) => (/[^\n]/.test(text) ? text.replace(/\n+$/, '') : text)
|
export const stripTrailingPasteNewlines = (text: string) => (/[^\n]/.test(text) ? text.replace(/\n+$/, '') : text)
|
||||||
|
|
@ -97,18 +86,18 @@ export const toolTrailLabel = (name: string) =>
|
||||||
.join(' ') || name
|
.join(' ') || name
|
||||||
|
|
||||||
export const formatToolCall = (name: string, context = '') => {
|
export const formatToolCall = (name: string, context = '') => {
|
||||||
|
const label = toolTrailLabel(name)
|
||||||
const preview = compactPreview(context, 64)
|
const preview = compactPreview(context, 64)
|
||||||
|
|
||||||
return preview ? `${toolTrailLabel(name)}("${preview}")` : toolTrailLabel(name)
|
return preview ? `${label}("${preview}")` : label
|
||||||
}
|
}
|
||||||
|
|
||||||
export const buildToolTrailLine = (name: string, context: string, error?: boolean, note?: string): string => {
|
export const buildToolTrailLine = (name: string, context: string, error?: boolean, note?: string) => {
|
||||||
const detail = compactPreview(note ?? '', 72)
|
const detail = compactPreview(note ?? '', 72)
|
||||||
|
|
||||||
return `${formatToolCall(name, context)}${detail ? ` :: ${detail}` : ''} ${error ? ' ✗' : ' ✓'}`
|
return `${formatToolCall(name, context)}${detail ? ` :: ${detail}` : ''} ${error ? ' ✗' : ' ✓'}`
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Tool completed / failed row in the inline trail (not CoT prose). */
|
|
||||||
export const isToolTrailResultLine = (line: string) => line.endsWith(' ✓') || line.endsWith(' ✗')
|
export const isToolTrailResultLine = (line: string) => line.endsWith(' ✓') || line.endsWith(' ✗')
|
||||||
|
|
||||||
export const parseToolTrailResultLine = (line: string) => {
|
export const parseToolTrailResultLine = (line: string) => {
|
||||||
|
|
@ -133,10 +122,8 @@ export const parseToolTrailResultLine = (line: string) => {
|
||||||
return { call: body, detail: '', mark }
|
return { call: body, detail: '', mark }
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Ephemeral status lines that should vanish once the next phase starts. */
|
|
||||||
export const isTransientTrailLine = (line: string) => line.startsWith('drafting ') || line === 'analyzing tool output…'
|
export const isTransientTrailLine = (line: string) => line.startsWith('drafting ') || line === 'analyzing tool output…'
|
||||||
|
|
||||||
/** Whether a persisted/activity tool line belongs to the same tool label as a newer line. */
|
|
||||||
export const sameToolTrailGroup = (label: string, entry: string) =>
|
export const sameToolTrailGroup = (label: string, entry: string) =>
|
||||||
entry === `${label} ✓` ||
|
entry === `${label} ✓` ||
|
||||||
entry === `${label} ✗` ||
|
entry === `${label} ✗` ||
|
||||||
|
|
@ -144,7 +131,6 @@ export const sameToolTrailGroup = (label: string, entry: string) =>
|
||||||
entry.startsWith(`${label} ::`) ||
|
entry.startsWith(`${label} ::`) ||
|
||||||
entry.startsWith(`${label}:`)
|
entry.startsWith(`${label}:`)
|
||||||
|
|
||||||
/** Index of the last non-result trail line, or -1. */
|
|
||||||
export const lastCotTrailIndex = (trail: readonly string[]) => {
|
export const lastCotTrailIndex = (trail: readonly string[]) => {
|
||||||
for (let i = trail.length - 1; i >= 0; i--) {
|
for (let i = trail.length - 1; i >= 0; i--) {
|
||||||
if (!isToolTrailResultLine(trail[i]!)) {
|
if (!isToolTrailResultLine(trail[i]!)) {
|
||||||
|
|
@ -168,10 +154,7 @@ export const estimateRows = (text: string, w: number, compact = false) => {
|
||||||
const lang = maybeFence[2]!.trim()
|
const lang = maybeFence[2]!.trim()
|
||||||
|
|
||||||
if (!fence) {
|
if (!fence) {
|
||||||
fence = {
|
fence = { char: marker[0] as '`' | '~', len: marker.length }
|
||||||
char: marker[0] as '`' | '~',
|
|
||||||
len: marker.length
|
|
||||||
}
|
|
||||||
|
|
||||||
if (lang) {
|
if (lang) {
|
||||||
rows += Math.ceil((`─ ${lang}`.length || 1) / w)
|
rows += Math.ceil((`─ ${lang}`.length || 1) / w)
|
||||||
|
|
@ -204,14 +187,11 @@ export const estimateRows = (text: string, w: number, compact = false) => {
|
||||||
|
|
||||||
export const flat = (r: Record<string, string[]>) => Object.values(r).flat()
|
export const flat = (r: Record<string, string[]>) => Object.values(r).flat()
|
||||||
|
|
||||||
const COMPACT_NUMBER = new Intl.NumberFormat('en-US', {
|
const COMPACT_NUMBER = new Intl.NumberFormat('en-US', { maximumFractionDigits: 1, notation: 'compact' })
|
||||||
maximumFractionDigits: 1,
|
|
||||||
notation: 'compact'
|
|
||||||
})
|
|
||||||
|
|
||||||
export const fmtK = (n: number) => COMPACT_NUMBER.format(n).replace(/[KMBT]$/, s => s.toLowerCase())
|
export const fmtK = (n: number) => COMPACT_NUMBER.format(n).replace(/[KMBT]$/, s => s.toLowerCase())
|
||||||
|
|
||||||
export const pick = <T>(a: T[]) => a[Math.floor(Math.random() * a.length)]!
|
export const pick = <T>(a: T[]) => a[Math.floor(Math.random() * a.length)]!
|
||||||
|
|
||||||
export const isPasteBackedText = (text: string): boolean =>
|
export const isPasteBackedText = (text: string) =>
|
||||||
/\[\[paste:\d+(?:[^\n]*?)\]\]|\[paste #\d+ (?:attached|excerpt)(?:[^\n]*?)\]/.test(text)
|
/\[\[paste:\d+(?:[^\n]*?)\]\]|\[paste #\d+ (?:attached|excerpt)(?:[^\n]*?)\]/.test(text)
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,3 @@
|
||||||
export const INTERPOLATION_RE = /\{!(.+?)\}/g
|
export const INTERPOLATION_RE = /\{!(.+?)\}/g
|
||||||
|
|
||||||
export const hasInterpolation = (s: string) => {
|
export const hasInterpolation = (s: string) => /\{!.+?\}/.test(s)
|
||||||
INTERPOLATION_RE.lastIndex = 0
|
|
||||||
|
|
||||||
return INTERPOLATION_RE.test(s)
|
|
||||||
}
|
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
export interface ActiveTool {
|
export interface ActiveTool {
|
||||||
|
context?: string
|
||||||
id: string
|
id: string
|
||||||
name: string
|
name: string
|
||||||
context?: string
|
|
||||||
startedAt?: number
|
startedAt?: number
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -36,11 +36,11 @@ export interface ClarifyReq {
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface Msg {
|
export interface Msg {
|
||||||
|
info?: SessionInfo
|
||||||
|
kind?: 'intro' | 'panel' | 'slash' | 'trail'
|
||||||
|
panelData?: PanelData
|
||||||
role: Role
|
role: Role
|
||||||
text: string
|
text: string
|
||||||
kind?: 'intro' | 'panel' | 'slash' | 'trail'
|
|
||||||
info?: SessionInfo
|
|
||||||
panelData?: PanelData
|
|
||||||
thinking?: string
|
thinking?: string
|
||||||
thinkingTokens?: number
|
thinkingTokens?: number
|
||||||
toolTokens?: number
|
toolTokens?: number
|
||||||
|
|
@ -76,6 +76,7 @@ export interface Usage {
|
||||||
export interface SudoReq {
|
export interface SudoReq {
|
||||||
requestId: string
|
requestId: string
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface SecretReq {
|
export interface SecretReq {
|
||||||
envVar: string
|
envVar: string
|
||||||
prompt: string
|
prompt: string
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue