mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-07-09 13:21:42 +00:00
opentui(v6): simplify the background-activity work (dedup + dead-code removal)
/simplify pass over this session's diff (4 cleanup agents → applied the high-value
findings):
- reuse: extract the duplicated truncRight/truncLeft (statusBar/agentsDashboard/
backgroundPanel) to logic/truncate.ts; export DONE_STATUSES/procIsRunning from
backgroundActivity and drop backgroundPanel's re-declared copy.
- simplification: remove dead/speculative exports (upsertNotification,
clearNotificationsByKey, BackgroundRun) — the campaign models notifications as
Message rows, not a notifications array — and drop runningCount's always-empty
`runs` param; collapse notificationDispatcher's always-true `card` field into a
plain notificationOsc(): TermNotification | undefined.
- efficiency: the bg-process poll now idles at 30s (was a flat 8s) and tightens to
8s only when something is running — most sessions have zero background processes.
- altitude: the `⚡ agents` chip joins the statusSegments width-ladder (was an inline
width gate) so all bar segments share one drop policy; refreshed the stale
statusBar header (bg: is wired now, not "reserved").
Net −95 LOC. Gate green. Skipped (noted): statusColor merge (divergent domains),
the pushNotification double-clone (necessary — guards the Solid aliasing footgun),
moving the notification-card dispatch out of messageLine, and the Message-row model
(deliberate "inline in transcript" design).
This commit is contained in:
parent
5c5a1fec4b
commit
76eab10b14
11 changed files with 95 additions and 190 deletions
|
|
@ -594,14 +594,17 @@ export const run = Effect.fn('Tui.run')(function* (input: TuiInput) {
|
|||
// Live backend: drive a session (create + optional initial prompt) concurrently.
|
||||
if (!input.fake) yield* Effect.forkScoped(bootstrapSession(gateway, store, input))
|
||||
|
||||
// Ambient `bg:` badge (A): poll the OS-process registry on a slow interval so
|
||||
// the status bar reflects running background processes even with the panel
|
||||
// closed. Cheap local RPC; scoped fiber → auto-cancelled on shutdown.
|
||||
// Ambient `bg:` badge (A): poll the OS-process registry so the status bar
|
||||
// reflects running background processes even with the panel closed. Cheap
|
||||
// local RPC; scoped fiber → auto-cancelled on shutdown. Adaptive interval:
|
||||
// most sessions have ZERO background processes, so idle-poll slowly (30s)
|
||||
// and tighten to 8s only once something is running.
|
||||
if (!input.fake)
|
||||
yield* Effect.forkScoped(
|
||||
Effect.gen(function* () {
|
||||
while (true) {
|
||||
yield* Effect.sleep('8 seconds')
|
||||
const idle = store.state.backgroundProcesses.length === 0
|
||||
yield* Effect.sleep(idle ? '30 seconds' : '8 seconds')
|
||||
yield* Effect.promise(() =>
|
||||
backgroundOps
|
||||
.list()
|
||||
|
|
|
|||
|
|
@ -29,14 +29,6 @@ export interface BackgroundProcess {
|
|||
uptimeSeconds: number
|
||||
}
|
||||
|
||||
export interface BackgroundRun {
|
||||
id: string
|
||||
label: string
|
||||
status: 'running' | 'complete' | 'failed' | 'cancelled'
|
||||
startedAt?: number
|
||||
summary?: string
|
||||
}
|
||||
|
||||
/** Loose-read a string field off an `unknown` object (slash.ts `readStr` style). */
|
||||
function readStr(value: unknown, key: string): string | undefined {
|
||||
if (!value || typeof value !== 'object') return undefined
|
||||
|
|
@ -63,12 +55,10 @@ function coerceLevel(value: unknown): ActivityNotification['level'] {
|
|||
* card has nothing to show). Maps snake_case `ttl_ms` → `ttlMs`, coerces a
|
||||
* garbage/missing `level` to 'info', and defaults `kind` to ''.
|
||||
*
|
||||
* id resolution (dedupe key): prefer the wire `id`, then fall back to `key`. If
|
||||
* BOTH are missing we synthesize `id = `n:${text}`` rather than minting a random
|
||||
* id — a random id would make every re-emit of the same text a NEW card, so a
|
||||
* text-derived stable id keeps dedupe (upsertNotification) working for
|
||||
* gateways that don't send ids. The original `key` (if any) is preserved
|
||||
* separately so notification.clear by key still targets the right rows.
|
||||
* id resolution: prefer the wire `id`, then fall back to `key`, else synthesize
|
||||
* `id = `n:${text}`` (a stable, text-derived id rather than a random one). The
|
||||
* original `key` (if any) is preserved separately so notification.clear by key
|
||||
* still targets the right cards.
|
||||
*/
|
||||
export function parseNotification(payload: unknown): ActivityNotification | null {
|
||||
const text = readStr(payload, 'text')
|
||||
|
|
@ -87,25 +77,6 @@ export function parseNotification(payload: unknown): ActivityNotification | null
|
|||
return out
|
||||
}
|
||||
|
||||
/** Dedupe-by-id upsert: replace an existing item with the same id, else append.
|
||||
* Returns a NEW array (never mutates the input). */
|
||||
export function upsertNotification(
|
||||
list: readonly ActivityNotification[],
|
||||
n: ActivityNotification
|
||||
): ActivityNotification[] {
|
||||
const idx = list.findIndex(item => item.id === n.id)
|
||||
if (idx === -1) return [...list, n]
|
||||
const next = list.slice()
|
||||
next[idx] = n
|
||||
return next
|
||||
}
|
||||
|
||||
/** Drop every notification whose `key` matches (notification.clear). Returns a
|
||||
* NEW array. Notifications without a key are never cleared this way. */
|
||||
export function clearNotificationsByKey(list: readonly ActivityNotification[], key: string): ActivityNotification[] {
|
||||
return list.filter(n => n.key !== key)
|
||||
}
|
||||
|
||||
/** Parse an `agents.list` result ({processes:[...]}) → BackgroundProcess[],
|
||||
* skipping malformed rows (a row missing session_id/command is dropped, not
|
||||
* defaulted). snake_case `session_id`/`uptime_seconds` → camelCase; a missing
|
||||
|
|
@ -134,17 +105,18 @@ export function parseProcessList(result: unknown): BackgroundProcess[] {
|
|||
* status vocabulary is open-ended and we'd rather over-count the ambient badge
|
||||
* than silently hide a still-live process under an unfamiliar status string.
|
||||
* Matched case-insensitively after trimming. */
|
||||
const DONE_STATUSES = new Set(['exited', 'failed', 'complete', 'done', 'killed'])
|
||||
/** Terminal (no-longer-running) process statuses — exported as the single
|
||||
* source of truth (the panel imports `procIsRunning` rather than re-declaring). */
|
||||
export const DONE_STATUSES = new Set(['exited', 'failed', 'complete', 'done', 'killed'])
|
||||
|
||||
function procIsRunning(status: string): boolean {
|
||||
/** Whether a process status is "running-ish": NOT one of DONE_STATUSES. Lenient
|
||||
* by design — the gateway's status vocabulary is open-ended, so we over-count
|
||||
* rather than hide a live process under an unfamiliar status. Case-insensitive. */
|
||||
export function procIsRunning(status: string): boolean {
|
||||
return !DONE_STATUSES.has(status.trim().toLowerCase())
|
||||
}
|
||||
|
||||
/** Count of "currently running" things for the ambient badge: runs whose
|
||||
* status is 'running', plus processes whose status is running-ish (anything
|
||||
* that isn't a terminal status — see DONE_STATUSES). */
|
||||
export function runningCount(runs: readonly BackgroundRun[], procs: readonly BackgroundProcess[]): number {
|
||||
const runningRuns = runs.filter(r => r.status === 'running').length
|
||||
const runningProcs = procs.filter(p => procIsRunning(p.status)).length
|
||||
return runningRuns + runningProcs
|
||||
/** Count of running background processes (the ambient `bg:` badge). */
|
||||
export function runningCount(procs: readonly BackgroundProcess[]): number {
|
||||
return procs.filter(p => procIsRunning(p.status)).length
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,20 +1,13 @@
|
|||
/**
|
||||
* Notification channel decision — pure routing for an ActivityNotification.
|
||||
* Every notification gets the inline transcript card; only "important" ones
|
||||
* additionally fire a desktop/terminal OSC notification (to pull the user back
|
||||
* to the terminal). The OSC payload shape is termChrome's `TermNotification`;
|
||||
* the boundary owns the actual escape-sequence write (termChrome.notifySequences).
|
||||
* Notification → desktop-OSC decision. EVERY notification renders an inline
|
||||
* transcript card (so there's nothing to decide there); this only decides whether
|
||||
* a notification is important enough to ALSO fire a desktop/terminal OSC ping
|
||||
* (to pull the user back). The OSC payload is termChrome's `TermNotification`;
|
||||
* the boundary (terminalChrome) owns the actual escape-sequence write.
|
||||
*/
|
||||
import type { ActivityNotification } from './backgroundActivity.ts'
|
||||
import type { TermNotification } from './termChrome.ts'
|
||||
|
||||
export interface NotificationChannels {
|
||||
/** Always true — every notification gets the inline transcript card. */
|
||||
card: boolean
|
||||
/** Present only for "terminal/important" notifications (see notificationChannels). */
|
||||
osc?: TermNotification
|
||||
}
|
||||
|
||||
/** Kind substrings that mark a "the work finished, look here" notification —
|
||||
* matched case-insensitively anywhere in the kind. */
|
||||
const COMPLETION_KIND_HINTS = ['complete', 'done', 'finish']
|
||||
|
|
@ -26,13 +19,11 @@ function isImportant(n: ActivityNotification): boolean {
|
|||
}
|
||||
|
||||
/**
|
||||
* Decide the output channels for `n`: ALWAYS the card; ADD an OSC desktop
|
||||
* notification when the notification is important enough to interrupt —
|
||||
* level 'error'/'warn', or a kind containing 'complete'/'done'/'finish'
|
||||
* (case-insensitive). The OSC always titles 'Hermes' with the notification
|
||||
* text as the body.
|
||||
* The desktop OSC notification for `n`, or `undefined` when it's not important
|
||||
* enough to interrupt — level 'error'/'warn', or a kind containing
|
||||
* 'complete'/'done'/'finish' (case-insensitive). Title is always 'Hermes' with
|
||||
* the notification text as the body.
|
||||
*/
|
||||
export function notificationChannels(n: ActivityNotification): NotificationChannels {
|
||||
if (!isImportant(n)) return { card: true }
|
||||
return { card: true, osc: { body: n.text, title: 'Hermes' } }
|
||||
export function notificationOsc(n: ActivityNotification): TermNotification | undefined {
|
||||
return isImportant(n) ? { body: n.text, title: 'Hermes' } : undefined
|
||||
}
|
||||
|
|
|
|||
17
ui-opentui/src/logic/truncate.ts
Normal file
17
ui-opentui/src/logic/truncate.ts
Normal file
|
|
@ -0,0 +1,17 @@
|
|||
/**
|
||||
* One-line string truncation helpers shared by the chrome views (status bar,
|
||||
* agents dashboard, background panel) — keep them here so the ellipsis rule
|
||||
* doesn't drift between copies.
|
||||
*/
|
||||
|
||||
/** Keep the HEAD of a string, suffixing `…` when it must clip (e.g. a goal/command row). */
|
||||
export function truncRight(s: string, max: number): string {
|
||||
if (max <= 1) return s.length > max ? '…' : s
|
||||
return s.length <= max ? s : s.slice(0, max - 1) + '…'
|
||||
}
|
||||
|
||||
/** Keep the TAIL of a string, prefixing `…` when it must clip (e.g. a deep cwd path). */
|
||||
export function truncLeft(s: string, max: number): string {
|
||||
if (max <= 1) return s.length > max ? '…' : s
|
||||
return s.length <= max ? s : '…' + s.slice(s.length - max + 1)
|
||||
}
|
||||
|
|
@ -6,14 +6,10 @@
|
|||
import { describe, expect, test } from 'vitest'
|
||||
|
||||
import {
|
||||
type ActivityNotification,
|
||||
type BackgroundProcess,
|
||||
type BackgroundRun,
|
||||
clearNotificationsByKey,
|
||||
parseNotification,
|
||||
parseProcessList,
|
||||
runningCount,
|
||||
upsertNotification
|
||||
runningCount
|
||||
} from '../logic/backgroundActivity.ts'
|
||||
|
||||
describe('parseNotification', () => {
|
||||
|
|
@ -46,13 +42,10 @@ describe('parseNotification', () => {
|
|||
expect(n?.key).toBe('k-only')
|
||||
})
|
||||
|
||||
test('no id and no key → synthesized id `n:${text}` so dedupe still works', () => {
|
||||
test('no id and no key → synthesized stable id `n:${text}`', () => {
|
||||
const n = parseNotification({ text: 'build finished' })
|
||||
expect(n?.id).toBe('n:build finished')
|
||||
expect(n?.key).toBeUndefined()
|
||||
// two re-emits of the same text dedupe to one row
|
||||
const list = upsertNotification(upsertNotification([], n!), parseNotification({ text: 'build finished' })!)
|
||||
expect(list).toHaveLength(1)
|
||||
})
|
||||
|
||||
test('id is preferred over key when both present', () => {
|
||||
|
|
@ -65,40 +58,6 @@ describe('parseNotification', () => {
|
|||
})
|
||||
})
|
||||
|
||||
describe('upsertNotification', () => {
|
||||
const base: ActivityNotification = { id: 'a', kind: '', level: 'info', text: 'first' }
|
||||
|
||||
test('appends a new id, returns a NEW array', () => {
|
||||
const list: readonly ActivityNotification[] = [base]
|
||||
const next = upsertNotification(list, { id: 'b', kind: '', level: 'info', text: 'second' })
|
||||
expect(next).toHaveLength(2)
|
||||
expect(next).not.toBe(list)
|
||||
expect(list).toHaveLength(1) // input untouched
|
||||
})
|
||||
|
||||
test('replaces an existing id in place (dedupe)', () => {
|
||||
const list: ActivityNotification[] = [base, { id: 'b', kind: '', level: 'info', text: 'second' }]
|
||||
const next = upsertNotification(list, { id: 'a', kind: 'x', level: 'error', text: 'updated' })
|
||||
expect(next).toHaveLength(2)
|
||||
expect(next[0]).toEqual({ id: 'a', kind: 'x', level: 'error', text: 'updated' })
|
||||
expect(next[1]!.id).toBe('b')
|
||||
})
|
||||
})
|
||||
|
||||
describe('clearNotificationsByKey', () => {
|
||||
test('removes all with the matching key; keeps others and keyless rows', () => {
|
||||
const list: ActivityNotification[] = [
|
||||
{ id: '1', key: 'k', kind: '', level: 'info', text: 'a' },
|
||||
{ id: '2', key: 'other', kind: '', level: 'info', text: 'b' },
|
||||
{ id: '3', kind: '', level: 'info', text: 'c' },
|
||||
{ id: '4', key: 'k', kind: '', level: 'info', text: 'd' }
|
||||
]
|
||||
const next = clearNotificationsByKey(list, 'k')
|
||||
expect(next.map(n => n.id)).toEqual(['2', '3'])
|
||||
expect(next).not.toBe(list)
|
||||
})
|
||||
})
|
||||
|
||||
describe('parseProcessList', () => {
|
||||
test('maps good rows, snake_case → camelCase', () => {
|
||||
expect(
|
||||
|
|
@ -136,12 +95,6 @@ describe('parseProcessList', () => {
|
|||
})
|
||||
|
||||
describe('runningCount', () => {
|
||||
const runs: BackgroundRun[] = [
|
||||
{ id: 'r1', label: 'A', status: 'running' },
|
||||
{ id: 'r2', label: 'B', status: 'complete' },
|
||||
{ id: 'r3', label: 'C', status: 'running' },
|
||||
{ id: 'r4', label: 'D', status: 'failed' }
|
||||
]
|
||||
const procs: BackgroundProcess[] = [
|
||||
{ command: 'a', sessionId: 's1', status: 'running', uptimeSeconds: 1 },
|
||||
{ command: 'b', sessionId: 's2', status: 'exited', uptimeSeconds: 1 },
|
||||
|
|
@ -150,12 +103,12 @@ describe('runningCount', () => {
|
|||
{ command: 'e', sessionId: 's5', status: 'killed', uptimeSeconds: 1 }
|
||||
]
|
||||
|
||||
test('counts running runs + running-ish processes (lenient on unknown statuses)', () => {
|
||||
// runs: r1, r3 = 2; procs: running + Sleeping = 2 (exited/DONE/killed excluded)
|
||||
expect(runningCount(runs, procs)).toBe(4)
|
||||
test('counts running-ish processes (lenient on unknown statuses)', () => {
|
||||
// running + Sleeping = 2 (exited/DONE/killed excluded)
|
||||
expect(runningCount(procs)).toBe(2)
|
||||
})
|
||||
|
||||
test('empty inputs → 0', () => {
|
||||
expect(runningCount([], [])).toBe(0)
|
||||
test('empty input → 0', () => {
|
||||
expect(runningCount([])).toBe(0)
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -1,43 +1,38 @@
|
|||
/**
|
||||
* Notification channel-decision tests. Card is always on; OSC fires only for
|
||||
* important notifications (error/warn level, or a completion-ish kind).
|
||||
* notificationOsc — every notification shows an inline card; this only decides
|
||||
* whether one ALSO fires a desktop OSC (error/warn level, or a completion-ish kind).
|
||||
*/
|
||||
import { describe, expect, test } from 'vitest'
|
||||
|
||||
import type { ActivityNotification } from '../logic/backgroundActivity.ts'
|
||||
import { notificationChannels } from '../logic/notificationDispatcher.ts'
|
||||
import { notificationOsc } from '../logic/notificationDispatcher.ts'
|
||||
|
||||
function notif(over: Partial<ActivityNotification>): ActivityNotification {
|
||||
return { id: 'n', kind: '', level: 'info', text: 'something happened', ...over }
|
||||
}
|
||||
|
||||
describe('notificationChannels', () => {
|
||||
test('card is always true', () => {
|
||||
expect(notificationChannels(notif({})).card).toBe(true)
|
||||
expect(notificationChannels(notif({ level: 'error' })).card).toBe(true)
|
||||
})
|
||||
|
||||
describe('notificationOsc', () => {
|
||||
test('plain info → no osc', () => {
|
||||
expect(notificationChannels(notif({ level: 'info', kind: 'progress' })).osc).toBeUndefined()
|
||||
expect(notificationOsc(notif({ level: 'info', kind: 'progress' }))).toBeUndefined()
|
||||
expect(notificationOsc(notif({ kind: 'started' }))).toBeUndefined()
|
||||
})
|
||||
|
||||
test('error and warn levels fire osc', () => {
|
||||
expect(notificationChannels(notif({ level: 'error' })).osc).toBeDefined()
|
||||
expect(notificationChannels(notif({ level: 'warn' })).osc).toBeDefined()
|
||||
expect(notificationOsc(notif({ level: 'error' }))).toBeDefined()
|
||||
expect(notificationOsc(notif({ level: 'warn' }))).toBeDefined()
|
||||
})
|
||||
|
||||
test('completion-ish kinds fire osc, case-insensitive (complete/done/finish)', () => {
|
||||
expect(notificationChannels(notif({ kind: 'task.complete' })).osc).toBeDefined()
|
||||
expect(notificationChannels(notif({ kind: 'JOB_DONE' })).osc).toBeDefined()
|
||||
expect(notificationChannels(notif({ kind: 'Finished' })).osc).toBeDefined()
|
||||
// substring match anywhere in the kind
|
||||
expect(notificationChannels(notif({ kind: 'agent.run.completed' })).osc).toBeDefined()
|
||||
// a non-completion info kind stays card-only
|
||||
expect(notificationChannels(notif({ kind: 'started' })).osc).toBeUndefined()
|
||||
test('completion-ish kinds fire osc, case-insensitive (complete/done/finish, substring)', () => {
|
||||
expect(notificationOsc(notif({ kind: 'task.complete' }))).toBeDefined()
|
||||
expect(notificationOsc(notif({ kind: 'JOB_DONE' }))).toBeDefined()
|
||||
expect(notificationOsc(notif({ kind: 'Finished' }))).toBeDefined()
|
||||
expect(notificationOsc(notif({ kind: 'agent.run.completed' }))).toBeDefined()
|
||||
})
|
||||
|
||||
test('osc body == text, title == "Hermes"', () => {
|
||||
const ch = notificationChannels(notif({ level: 'error', text: 'build broke' }))
|
||||
expect(ch.osc).toEqual({ body: 'build broke', title: 'Hermes' })
|
||||
expect(notificationOsc(notif({ level: 'error', text: 'build broke' }))).toEqual({
|
||||
body: 'build broke',
|
||||
title: 'Hermes'
|
||||
})
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -109,6 +109,7 @@ describe('store.applyInfo — chrome merge', () => {
|
|||
describe('statusSegments — progressive disclosure table (chrome v3 order)', () => {
|
||||
test('full width shows everything', () => {
|
||||
expect(statusSegments(220)).toEqual({
|
||||
agents: true,
|
||||
ctxDetail: true,
|
||||
cost: true,
|
||||
up: true,
|
||||
|
|
|
|||
|
|
@ -13,6 +13,7 @@ import { useKeyboard } from '@opentui/solid'
|
|||
import { createSignal, For, onMount, Show } from 'solid-js'
|
||||
|
||||
import type { SubagentInfo, TraceEntry } from '../../logic/store.ts'
|
||||
import { truncRight } from '../../logic/truncate.ts'
|
||||
import { useDimensions } from '../dimensions.tsx'
|
||||
import { useCloseLayer } from '../keymap.tsx'
|
||||
import { useTheme } from '../theme.tsx'
|
||||
|
|
@ -27,12 +28,6 @@ function statusColor(status: string, theme: ReturnType<typeof useTheme>): string
|
|||
return c.warn
|
||||
}
|
||||
|
||||
/** Keep the head of a string, ellipsizing when it must clip (one-line master rows). */
|
||||
function truncRight(s: string, max: number): string {
|
||||
if (max <= 1) return s.length > max ? '…' : s
|
||||
return s.length <= max ? s : s.slice(0, max - 1) + '…'
|
||||
}
|
||||
|
||||
/** Per-kind glyph + color for a trace entry — makes the detail pane read like a
|
||||
* transcript (tool calls pop, progress is quiet, the summary is the payoff). */
|
||||
function traceGlyph(kind: TraceEntry['kind']): string {
|
||||
|
|
|
|||
|
|
@ -6,41 +6,26 @@
|
|||
* The gateway only exposes a single STOP-ALL (`kill_all`), NOT per-process kill,
|
||||
* so the only action is `x` → stop all (no per-row kill). `r` refreshes; the
|
||||
* controller wires both to the gateway.
|
||||
*
|
||||
* NOTE: the contract spec'd `import { BackgroundProcess } from '../../logic/store.ts'`,
|
||||
* but the type actually lives in `backgroundActivity.ts` (store.ts does not
|
||||
* re-export it). Importing from the real source keeps `npm run check` green —
|
||||
* editing store.ts to add a re-export is out of fence (controller's job).
|
||||
*/
|
||||
import { type BoxRenderable } from '@opentui/core'
|
||||
import { useKeyboard } from '@opentui/solid'
|
||||
import { For, type JSXElement, onMount, Show } from 'solid-js'
|
||||
|
||||
import type { BackgroundProcess } from '../../logic/backgroundActivity.ts'
|
||||
import { procIsRunning, type BackgroundProcess } from '../../logic/backgroundActivity.ts'
|
||||
import { truncRight } from '../../logic/truncate.ts'
|
||||
import { useDimensions } from '../dimensions.tsx'
|
||||
import { useCloseLayer } from '../keymap.tsx'
|
||||
import { useTheme } from '../theme.tsx'
|
||||
|
||||
/** Terminal statuses (case-insensitive); anything else is treated as running —
|
||||
* the same lenient rule the store uses (backgroundActivity `DONE_STATUSES`). */
|
||||
const DONE_STATUSES = new Set(['exited', 'failed', 'complete', 'done', 'killed'])
|
||||
const isRunning = (status: string): boolean => !DONE_STATUSES.has(status.toLowerCase())
|
||||
|
||||
function statusColor(status: string, theme: ReturnType<typeof useTheme>): string {
|
||||
const c = theme().color
|
||||
const s = status.toLowerCase()
|
||||
if (s === 'failed' || s.includes('error')) return c.error
|
||||
if (s === 'exited' || s === 'complete' || s === 'done') return c.ok
|
||||
if (isRunning(status)) return c.accent
|
||||
if (procIsRunning(status)) return c.accent
|
||||
return c.muted
|
||||
}
|
||||
|
||||
/** Keep the head of a string, ellipsizing when it must clip (one-line rows). */
|
||||
function truncRight(s: string, max: number): string {
|
||||
if (max <= 1) return s.length > max ? '…' : s
|
||||
return s.length <= max ? s : s.slice(0, max - 1) + '…'
|
||||
}
|
||||
|
||||
/** Compact inline uptime: <60 → 'Ns', <3600 → 'Nm', else 'Hh MMm'. */
|
||||
function fmtUptime(seconds: number): string {
|
||||
const s = Math.max(0, Math.floor(seconds))
|
||||
|
|
@ -61,7 +46,7 @@ export function BackgroundPanel(props: {
|
|||
const dims = useDimensions()
|
||||
let rootRef: BoxRenderable | undefined
|
||||
|
||||
const running = () => props.processes.filter(p => isRunning(p.status)).length
|
||||
const running = () => props.processes.filter(p => procIsRunning(p.status)).length
|
||||
|
||||
// Focus the root so the focus-within close layer is active; refresh once on
|
||||
// open so the list is fresh.
|
||||
|
|
|
|||
|
|
@ -17,9 +17,9 @@
|
|||
* 10–14 cells by terminal width (`ctxBarCells`), vs the old 5.
|
||||
* - Responsive = drop, don't restack: as the terminal narrows, tail segments
|
||||
* drop WHOLE in reverse priority (mcp → bg → profile → cmp → up → cost →
|
||||
* ctx detail collapsing to a bare `ctx: 42%`) via the pure, table-tested
|
||||
* `statusSegments` ladder. The health dot, model and ctx % are pinned.
|
||||
* Nothing truncates mid-segment, so the row NEVER wraps or restacks.
|
||||
* ctx detail collapsing to a bare `ctx: 42%`, then the `⚡ agents` chip) via
|
||||
* the pure, table-tested `statusSegments` ladder. The health dot, model and
|
||||
* ctx % are pinned. Nothing truncates mid-segment, so the row NEVER wraps.
|
||||
*
|
||||
* A pending update (`info.update_behind > 0`) BORROWS the whole line as a
|
||||
* transient notice; it dismisses on Esc or after NOTICE_TTL_MS.
|
||||
|
|
@ -28,10 +28,11 @@
|
|||
* correct blue surface), `statusFg` for the model/profile/percent, muted
|
||||
* metrics + labels, ok/warn dot, level-tinted ctx bar and cmp count.
|
||||
*
|
||||
* Background-activity chrome (glitch 2026-06-13): `⚡ N` = running subagents
|
||||
* (folded here from the old agents-tray line), `bg: N` = running OS background
|
||||
* processes (polled from `agents.list`). Both hidden at zero.
|
||||
*
|
||||
* Parity notes (data that does not reach this TUI yet — reported, not faked):
|
||||
* - `bg: N` (background tasks): the OpenTUI store has no background-task
|
||||
* tracking (Ink counts `prompt.background` task_ids + `background.complete`
|
||||
* locally); the segment slot exists in `statusSegments` but renders nothing.
|
||||
* - `display.show_cost`: Ink reads it from its `config.get` polling loop,
|
||||
* which this TUI doesn't have — cost shows whenever `usage.cost_usd` is
|
||||
* present instead.
|
||||
|
|
@ -43,6 +44,7 @@ import { createEffect, createMemo, createSignal, onCleanup, Show } from 'solid-j
|
|||
|
||||
import { runningCount } from '../logic/backgroundActivity.ts'
|
||||
import type { SessionStore } from '../logic/store.ts'
|
||||
import { truncLeft, truncRight } from '../logic/truncate.ts'
|
||||
import { isTrayAgent } from './agentsTray.tsx'
|
||||
import { useDimensions } from './dimensions.tsx'
|
||||
import { elapsedSeconds, useElapsedTick } from './elapsed.ts'
|
||||
|
|
@ -67,6 +69,8 @@ const NOTICE_TTL_MS = 30_000
|
|||
* Dot+model and the ctx % are pinned and never gated here; the cwd is gated
|
||||
* by its own leftover-width budget instead. */
|
||||
export interface StatusSegments {
|
||||
/** Running-subagents `⚡ N` chip — survives narrowest (drops last). */
|
||||
agents: boolean
|
||||
/** Full `ctx: ███░░ 42% · 84k` read-out; false → compact `ctx: 42%`. */
|
||||
ctxDetail: boolean
|
||||
cost: boolean
|
||||
|
|
@ -74,7 +78,7 @@ export interface StatusSegments {
|
|||
up: boolean
|
||||
compressions: boolean
|
||||
profile: boolean
|
||||
/** Background-tasks count — reserved; no store data feeds it yet (see header). */
|
||||
/** Running OS background-processes count (`bg: N`). */
|
||||
bg: boolean
|
||||
mcp: boolean
|
||||
}
|
||||
|
|
@ -82,6 +86,7 @@ export interface StatusSegments {
|
|||
export function statusSegments(cols: number): StatusSegments {
|
||||
const w = Math.max(1, Math.floor(cols || 1))
|
||||
return {
|
||||
agents: w >= 60,
|
||||
ctxDetail: w >= 72,
|
||||
cost: w >= 80,
|
||||
up: w >= 88,
|
||||
|
|
@ -156,18 +161,6 @@ function shortCwd(cwd: string): string {
|
|||
return segs.length <= 3 ? home : '…/' + segs.slice(-2).join('/')
|
||||
}
|
||||
|
||||
/** Keep the TAIL of a string, prefixing with `…` when it must be clipped. */
|
||||
function truncLeft(s: string, max: number): string {
|
||||
if (max <= 1) return s.length > max ? '…' : s
|
||||
return s.length <= max ? s : '…' + s.slice(s.length - max + 1)
|
||||
}
|
||||
|
||||
/** Keep the HEAD of a string, suffixing with `…` when it must be clipped. */
|
||||
function truncRight(s: string, max: number): string {
|
||||
if (max <= 1) return s.length > max ? '…' : s
|
||||
return s.length <= max ? s : s.slice(0, max - 1) + '…'
|
||||
}
|
||||
|
||||
/** A unicode meter: `███░░` filled to `pct`% over `width` cells (Ink ctxBar). */
|
||||
function ctxBar(pct: number, width: number): string {
|
||||
const filled = Math.max(0, Math.min(width, Math.round((pct / 100) * width)))
|
||||
|
|
@ -263,7 +256,7 @@ export function StatusBar(props: { store: SessionStore }) {
|
|||
// `bg: N` — running OS background processes (polled into the store); the
|
||||
// ambient half of the background-activity notifications (glitch 2026-06-13).
|
||||
const bgText = createMemo(() => {
|
||||
const n = runningCount([], props.store.state.backgroundProcesses)
|
||||
const n = runningCount(props.store.state.backgroundProcesses)
|
||||
return segs().bg && n > 0 ? `bg: ${n}` : ''
|
||||
})
|
||||
// `⚡ N` — running subagents. The ambient count lives HERE now (P4 input-density
|
||||
|
|
@ -271,7 +264,7 @@ export function StatusBar(props: { store: SessionStore }) {
|
|||
// composer — Down still expands it; this chip is the at-a-glance signal.
|
||||
const agentsText = createMemo(() => {
|
||||
const n = props.store.state.subagents.filter(isTrayAgent).length
|
||||
return n > 0 && dims().width >= 60 ? `⚡ ${n}` : ''
|
||||
return segs().agents && n > 0 ? `⚡ ${n}` : ''
|
||||
})
|
||||
|
||||
// The cwd flows LAST on the same line (not right-pinned): its budget is the
|
||||
|
|
|
|||
|
|
@ -20,7 +20,7 @@ import { useRenderer } from '@opentui/solid'
|
|||
import { createEffect, on } from 'solid-js'
|
||||
|
||||
import { installTerminalChrome, type TerminalChromeSeam } from '../boundary/termChrome.ts'
|
||||
import { notificationChannels } from '../logic/notificationDispatcher.ts'
|
||||
import { notificationOsc } from '../logic/notificationDispatcher.ts'
|
||||
import type { createSessionStore } from '../logic/store.ts'
|
||||
import { promptNotification, TURN_COMPLETE_NOTIFICATION } from '../logic/termChrome.ts'
|
||||
|
||||
|
|
@ -66,7 +66,7 @@ export function TerminalChrome(props: { store: Store; chrome?: TerminalChromeSea
|
|||
() => props.store.state.lastNotification,
|
||||
n => {
|
||||
if (!n) return
|
||||
const osc = notificationChannels(n).osc
|
||||
const osc = notificationOsc(n)
|
||||
if (osc) chrome.notify(osc)
|
||||
},
|
||||
{ defer: true }
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue