feat: better bg tasks

This commit is contained in:
Brooklyn Nicholson 2026-04-08 14:18:37 -05:00
parent af0f4a52fe
commit b597123489
9 changed files with 364 additions and 5670 deletions

5383
ui-tui/package-lock.json generated

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@ -3,24 +3,21 @@ import { Box, Text } from 'ink'
import type { Theme } from '../theme.js'
import type { ActivityItem } from '../types.js'
const toneColor = (item: ActivityItem, t: Theme) =>
item.tone === 'error' ? t.color.error : item.tone === 'warn' ? t.color.warn : t.color.dim
export function ActivityLane({ items, t }: { items: ActivityItem[]; t: Theme }) {
if (!items.length) {
return null
}
const visible = items.slice(-4)
return (
<Box flexDirection="column" marginTop={1}>
{visible.map(item => {
const color = item.tone === 'error' ? t.color.error : item.tone === 'warn' ? t.color.warn : t.color.dim
return (
<Text color={color} dimColor={item.tone === 'info'} key={item.id}>
{t.brand.tool} {item.text}
</Text>
)
})}
{items.slice(-4).map(item => (
<Text color={toneColor(item, t)} dimColor={item.tone === 'info'} key={item.id}>
{t.brand.tool} {item.text}
</Text>
))}
</Box>
)
}

View File

@ -20,7 +20,6 @@ export const MessageLine = memo(function MessageLine({
t: Theme
}) {
const { body, glyph, prefix } = ROLE[msg.role](t)
const contentWidth = Math.max(20, cols - 5)
if (msg.role === 'tool') {
return (
@ -61,7 +60,7 @@ export const MessageLine = memo(function MessageLine({
</Text>
</Box>
<Box width={contentWidth}>{content}</Box>
<Box width={Math.max(20, cols - 5)}>{content}</Box>
</Box>
</Box>
)

View File

@ -1,7 +1,7 @@
import { Text, useInput } from 'ink'
import { useEffect, useRef, useState } from 'react'
function wl(s: string, p: number) {
function wordLeft(s: string, p: number) {
let i = p - 1
while (i > 0 && /\s/.test(s[i]!)) {
@ -15,7 +15,7 @@ function wl(s: string, p: number) {
return Math.max(0, i)
}
function wr(s: string, p: number) {
function wordRight(s: string, p: number) {
let i = p
while (i < s.length && !/\s/.test(s[i]!)) {
@ -29,7 +29,7 @@ function wr(s: string, p: number) {
return i
}
const ESC = String.fromCharCode(0x1b)
const ESC = '\x1b'
const INV = ESC + '[7m'
const INV_OFF = ESC + '[27m'
const DIM = ESC + '[2m'
@ -63,6 +63,16 @@ export function TextInput({ value, onChange, onPaste, onSubmit, placeholder = ''
}
}, [value])
const commit = (v: string, c: number) => {
c = Math.max(0, Math.min(c, v.length))
setCur(c)
if (v !== value) {
selfChange.current = true
onChange(v)
}
}
const flushPaste = () => {
const pasted = pasteBuf.current
const at = pastePos.current
@ -85,9 +95,8 @@ export function TextInput({ value, onChange, onPaste, onSubmit, placeholder = ''
}
if (pasted.length && PRINTABLE.test(pasted)) {
const nv = v.slice(0, at) + pasted + v.slice(at)
selfChange.current = true
onChange(nv)
onChange(v.slice(0, at) + pasted + v.slice(at))
setCur(at + pasted.length)
}
}
@ -113,9 +122,8 @@ export function TextInput({ value, onChange, onPaste, onSubmit, placeholder = ''
return
}
let c = cur,
v = value
let c = cur
let v = value
const mod = k.ctrl || k.meta
if (k.home || (k.ctrl && inp === 'a')) {
@ -123,12 +131,12 @@ export function TextInput({ value, onChange, onPaste, onSubmit, placeholder = ''
} else if (k.end || (k.ctrl && inp === 'e')) {
c = v.length
} else if (k.leftArrow) {
c = mod ? wl(v, c) : Math.max(0, c - 1)
c = mod ? wordLeft(v, c) : Math.max(0, c - 1)
} else if (k.rightArrow) {
c = mod ? wr(v, c) : Math.min(v.length, c + 1)
c = mod ? wordRight(v, c) : Math.min(v.length, c + 1)
} else if ((k.backspace || k.delete) && c > 0) {
if (mod) {
const t = wl(v, c)
const t = wordLeft(v, c)
v = v.slice(0, t) + v.slice(c)
c = t
} else {
@ -136,7 +144,7 @@ export function TextInput({ value, onChange, onPaste, onSubmit, placeholder = ''
c--
}
} else if (k.ctrl && inp === 'w' && c > 0) {
const t = wl(v, c)
const t = wordLeft(v, c)
v = v.slice(0, t) + v.slice(c)
c = t
} else if (k.ctrl && inp === 'u') {
@ -145,9 +153,9 @@ export function TextInput({ value, onChange, onPaste, onSubmit, placeholder = ''
} else if (k.ctrl && inp === 'k') {
v = v.slice(0, c)
} else if (k.meta && inp === 'b') {
c = wl(v, c)
c = wordLeft(v, c)
} else if (k.meta && inp === 'f') {
c = wr(v, c)
c = wordRight(v, c)
} else if (inp.length > 0) {
const raw = inp.replace(BRACKET_PASTE, '').replace(/\r\n/g, '\n').replace(/\r/g, '\n')
@ -155,9 +163,7 @@ export function TextInput({ value, onChange, onPaste, onSubmit, placeholder = ''
return
}
const isMultiChar = raw.length > 1 || raw.includes('\n')
if (isMultiChar) {
if (raw.length > 1 || raw.includes('\n')) {
if (!pasteBuf.current) {
pastePos.current = c
}
@ -183,13 +189,7 @@ export function TextInput({ value, onChange, onPaste, onSubmit, placeholder = ''
return
}
c = Math.max(0, Math.min(c, v.length))
setCur(c)
if (v !== value) {
selfChange.current = true
onChange(v)
}
commit(v, c)
},
{ isActive: focus }
)

View File

@ -9,19 +9,19 @@ import type { ActiveTool } from '../types.js'
const THINK_POOL: BrailleSpinnerName[] = ['helix', 'breathe', 'orbit', 'dna', 'waverows', 'snake', 'pulse']
const TOOL_POOL: BrailleSpinnerName[] = ['cascade', 'scan', 'diagswipe', 'fillsweep', 'rain', 'columns', 'sparkle']
const pick = <T,>(arr: T[]) => arr[Math.floor(Math.random() * arr.length)]!
const pick = <T,>(a: T[]) => a[Math.floor(Math.random() * a.length)]!
function Spinner({ color, variant = 'think' }: { color: string; variant?: 'think' | 'tool' }) {
const [spin] = useState(() => spinners[pick(variant === 'tool' ? TOOL_POOL : THINK_POOL)])
const [i, setI] = useState(0)
const [frame, setFrame] = useState(0)
useEffect(() => {
const id = setInterval(() => setI(p => (p + 1) % spin.frames.length), spin.interval)
const id = setInterval(() => setFrame(f => (f + 1) % spin.frames.length), spin.interval)
return () => clearInterval(id)
}, [spin])
return <Text color={color}>{spin.frames[i]}</Text>
return <Text color={color}>{spin.frames[frame]}</Text>
}
export const Thinking = memo(function Thinking({
@ -36,9 +36,7 @@ export const Thinking = memo(function Thinking({
const [tick, setTick] = useState(0)
useEffect(() => {
const id = setInterval(() => {
setTick(v => v + 1)
}, 1100)
const id = setInterval(() => setTick(v => v + 1), 1100)
return () => clearInterval(id)
}, [])

View File

@ -39,9 +39,7 @@ export const HOTKEYS: [string, string][] = [
]
export const INTERPOLATION_RE = /\{!(.+?)\}/g
export const LONG_MSG = 300
export const MAX_CTX = 128_000
export const PLACEHOLDERS = [
'Ask me anything…',

View File

@ -62,7 +62,7 @@ export const DEFAULT_THEME: Theme = {
diffAdded: 'rgb(220,255,220)',
diffRemoved: 'rgb(255,220,220)',
diffAddedWord: 'rgb(36,138,61)',
diffRemovedWord: 'rgb(207,34,46)',
diffRemovedWord: 'rgb(207,34,46)'
},
brand: {
@ -110,7 +110,7 @@ export function fromSkin(
diffAdded: d.color.diffAdded,
diffRemoved: d.color.diffRemoved,
diffAddedWord: d.color.diffAddedWord,
diffRemovedWord: d.color.diffRemovedWord,
diffRemovedWord: d.color.diffRemovedWord
},
brand: {

View File

@ -24,9 +24,8 @@ export interface ClarifyReq {
export interface Msg {
role: Role
text: string
kind?: 'intro' | 'tool-active'
kind?: 'intro'
info?: SessionInfo
toolId?: string
}
export type Role = 'assistant' | 'system' | 'tool' | 'user'
@ -52,7 +51,6 @@ export interface Usage {
export interface SudoReq {
requestId: string
}
export interface SecretReq {
envVar: string
prompt: string
@ -72,7 +70,6 @@ export interface PendingPaste {
text: string
}
/** From `commands.catalog` — mirrors hermes_cli.commands COMMANDS + SUBCOMMANDS + skills. */
export interface SlashCatalog {
canon: Record<string, string>
pairs: [string, string][]