diff --git a/src/bots.css b/src/bots.css index 5f5f3b5..63b0e3b 100644 --- a/src/bots.css +++ b/src/bots.css @@ -57,6 +57,37 @@ --deck-accent-soft: rgb(139 124 246 / 0.18); --deck-accent-ink: #d9d3ff; --deck-numeral-font: 'Segoe UI Variable Display', var(--island-font); + /* The type scale, five steps: small, body, a title (a date written out), + display (the numbers a tab is about: light, tight, tabular) and hero + (the today tab's clock). A unit beside its number is set at + `--deck-unit` of it, never under the small step. */ + --deck-small: 12px; + --deck-body: 13.5px; + --deck-title: 20px; + --deck-display: 40px; + --deck-hero: 68px; + --deck-display-weight: 320; + --deck-display-tracking: -0.025em; + --deck-unit: max(var(--deck-small), 0.45em); +} + +/* + * A list that scrolls inside a deck tile: no bar; with `island-fade`, its + * edges fade into the tile rather than the island's black. + */ +.deck-scroll { + overflow-y: auto; + overscroll-behavior: contain; + scrollbar-width: none; +} +.deck-scroll::-webkit-scrollbar { + display: none; +} +.deck-scroll.island-fade::before { + background: linear-gradient(var(--deck-tile), transparent); +} +.deck-scroll.island-fade::after { + background: linear-gradient(transparent, var(--deck-tile)); } /* diff --git a/src/components/island/IslandBar.svelte b/src/components/island/IslandBar.svelte index a448987..595b264 100644 --- a/src/components/island/IslandBar.svelte +++ b/src/components/island/IslandBar.svelte @@ -1,14 +1,19 @@ @@ -45,6 +51,8 @@ import { bloom, blurAway, ROLL_MS, rollIn, rollOut } from '../../lib/island/motion'; import { restLine, robotWidth, sessionLine, type TimerSource } from '../../lib/island/pill-rest'; import { pillStops, stepStop, stopAt, WHEEL_STILL, wheelStep } from '../../lib/island/pill-stops'; + import { posed } from '../../lib/island/posed.svelte'; + import { fitWords, pillText, pillView } from '../../lib/island/pill-view'; import { QuotaWatch } from '../../lib/island/quotas.svelte'; import { requestQueue } from '../../lib/island/requests'; import { isLive } from '../../lib/island/sessions'; @@ -76,8 +84,10 @@ /** With a request to answer: the line, then what is asked and the three answers. */ const ASK = { w: 480, h: 78 }; const PEEK = { min: 240, max: 420, chrome: 82 }; - /** The collapsed pill with a conversation at work: its line, the face, the gaps and the padding, and the dots. */ - const LIVE = { min: 200, max: 360, chrome: 74 }; + /** The pill at rest: its words, the face, the gap and the padding. */ + const VIEW = { min: 150, max: 420, chrome: 68 }; + /** The longest detail the hover tells, cut on a word. */ + const DETAIL_MAX = 54; /** Each dot's hover target: wider than the dot. */ const DOT_STEP = 12; /** How long the answers take no click once a request shows. */ @@ -85,11 +95,13 @@ let barFace = $state(null); let statusWidth = $state(0); - let liveWidth = $state(0); + let viewWidth = $state(0); const lead = $derived(companion.members[0]!); /** The conversations at work, and the one the wheel picked among them. */ - const liveRows = $derived(sessions.rows.filter(isLive)); + /** Home's session rows; a capture pose (`sessions=none`, `pose.ts`) shows none, in the dev bundle only. */ + const rows = $derived(import.meta.env.DEV && posed.idle ? [] : sessions.rows); + const liveRows = $derived(rows.filter(isLive)); let picked = $state(null); const leadSession = $derived(liveRows.find((row) => row.id === picked) ?? sessions.lead); /** The thread followed from Home keeps the line whatever else starts (`followed.ts`). */ @@ -129,7 +141,7 @@ if (picked !== null && !liveRows.some((row) => row.id === picked)) picked = null; }); - const dots = $derived(pillDots(sessions.rows, companion.blocking)); + const dots = $derived(pillDots(rows, companion.blocking)); /** The pointer is over the dots' strip, and over which dot. */ let onDots = $state(false); let hoveredKey = $state(null); @@ -152,27 +164,50 @@ const inProject = $derived(ask ? companion.threads.some((thread) => thread.id === ask.request.threadId && thread.projectId) : false); /** Each row of answers arms on its own: the scope's too. */ const armKey = $derived(ask ? (scoping === ask.id ? `scope:${ask.id}` : ask.id) : null); - /** The last request a rule answered: the pill peeks with its line, a shield by the dots. */ + /** The last request a rule answered: told in the line a few seconds, its shield before it. */ const trace = $derived(companion.rules.trace); const traceLine = $derived(trace ? { text: fill(copy.allow.traced, { rule: trace.text }), tone: 'done' as const } : null); - /** The line the peek shows: a hovered dot, else the request, else a thread just ended, else the status. */ - const line = $derived.by(() => { - if (hoveredDot) return dotLine(hoveredDot); - if (ask && origin) return { text: queue.length > 1 ? `${origin.title} · ${fill(card.of, { index: '1', count: String(queue.length) })}` : origin.title, tone: 'attention' }; - if (told && frame.flashing) return told; - return traceLine ?? status; + /** The first request's thread, when Bots knows it. */ + const asker = $derived.by(() => { + const first = queue[0]; + const thread = first ? companion.threads.find((entry) => entry.id === first.request.threadId) : undefined; + return thread ? thread.title || strings.companion.untitled : null; }); - /** While the wheel turns, the hover's peek gives way to where the wheel lands; flashes, requests and a dot's name do not. */ + /** The row the pill follows alone: followed from Home, else picked with the wheel. */ + const focusRow = $derived(sessions.followedRow ?? (picked !== null ? (liveRows.find((row) => row.id === picked) ?? null) : null)); + /** What the pill says at rest (`pill-view.ts`). With a request to answer, the asking thread is named and the queue counted aside. */ + const view = $derived( + pillView({ + waiting: asking ? { count: 1, from: origin?.title ?? null } : { count: companion.blocking, from: asker }, + told: told && frame.flashing ? { text: told.text, tone: told.tone } : null, + rule: traceLine?.text ?? null, + rest: live && live.kind !== 'live' ? live : null, + robot: rest === 'robot', + focus: focusRow ? sessionLine(focusRow, (focusRow.projectId && companion.projects.get(focusRow.projectId)) || '') : null, + working: { count: liveRows.length, first: liveRows[0] ? liveRows[0].title || strings.companion.untitled : null }, + goal: (focusRow ?? liveRows.find((row) => row.progress && row.progress.total > 0))?.progress ?? null + }) + ); + const viewText = $derived(pillText(view)); + const meta = $derived(asking && queue.length > 1 ? fill(card.of, { index: '1', count: String(queue.length) }) : view.meta); + + /** The pill is focused from the keyboard: it tells the hover's detail. */ + let focused = $state(false); + /** While the wheel turns, the hover's detail gives way to where the wheel lands. */ let wheeling = $state(false); - const peeking = $derived((frame.size === 'peek' && (!wheeling || frame.flashing)) || asking || hoveredDot !== null || trace !== null); + /** Hovered or focused: the detail and the dots. */ + const hovered = $derived(((frame.hovering && !wheeling) || focused || onDots) && !companion.dropped); + /** The hover's detail replaces the rest's words; a request, a thing just told and a dot's name keep theirs. */ + const detail = $derived(hoveredDot !== null || (hovered && (view.state === 'rest' || view.state === 'working' || view.state === 'idle'))); + /** The line the detail shows: a hovered dot, else the status with the lead's step. */ + const line = $derived(hoveredDot ? dotLine(hoveredDot) : { text: fitWords(status.text, DETAIL_MAX), tone: status.tone }); + const peeking = $derived(detail || asking); - const dotsWidth = $derived((dots.length + (trace ? 1 : 0)) * DOT_STEP); + const dotsWidth = $derived(hovered ? dots.length * DOT_STEP : 0); const peekWidth = $derived(Math.round(Math.min(PEEK.max, Math.max(PEEK.min, statusWidth + PEEK.chrome + dotsWidth)))); - const collapsedWidth = $derived( - live ? Math.round(Math.min(LIVE.max, Math.max(LIVE.min, liveWidth + LIVE.chrome + dotsWidth))) : rest === 'robot' ? robotWidth(dotsWidth) : SIZES.collapsed.w - ); - const wanted = $derived(asking ? ASK : peeking ? { ...SIZES.peek, w: peekWidth } : { ...SIZES.collapsed, w: collapsedWidth }); + const restWidth = $derived(viewText ? Math.round(Math.min(VIEW.max, Math.max(VIEW.min, viewWidth + VIEW.chrome + dotsWidth))) : robotWidth(dotsWidth)); + const wanted = $derived(asking ? ASK : detail ? { ...SIZES.peek, w: peekWidth } : { ...SIZES.collapsed, w: restWidth }); // Over the dots the width holds: a dot's name is longer or shorter than the line, and the dots would slide from under the pointer. $effect(() => { const next = wanted; @@ -207,7 +242,7 @@ // The timer's drop leaves its time to the line while the line tells it. $effect(() => { - const inLine = !peeking && live?.kind === 'timer'; + const inLine = !peeking && view.glyph === 'timer'; untrack(() => (timerInLine = inLine)); }); $effect(() => () => (timerInLine = false)); @@ -291,18 +326,21 @@
- {#if live}{/if} + {#if viewText}{/if} {#if ask && said} {@const request = ask.request} @@ -428,63 +477,91 @@ .status[data-tone='failed'] { color: var(--island-danger); } .status[data-tone='talking'] { color: #b9aefb; } .status[data-tone='idle'] { color: var(--island-muted); } - /* Collapsed, the conversation at work: its project, then what it does. */ - .live { - color: var(--island-muted); - font-size: 12px; - } - .live b { - color: var(--island-text); - font-weight: 600; - } - /* The quota's or the music's mark before the line; the measure keeps its room. */ - .live :global(.kind), - .live-measure .kind { - display: inline-block; - width: 12px; - height: 12px; - margin-right: 5px; + /* + * The pill at rest: one line, larger than the hover's detail, the name or + * the count in bold. Only someone waiting on the user turns the words amber; + * the quota's and the timer's marks keep their own tint. + */ + .say, + .say-measure { + font-size: 14px; + letter-spacing: -0.005em; + } + .say { + --say: var(--island-text); + color: var(--say); + } + .say b, + .say-measure b { + font-weight: 650; + } + .say .after { + color: color-mix(in srgb, var(--say) 80%, transparent); + } + .say[data-tone='attention'] { --say: var(--island-attention); } + .say[data-tone='done'] { --say: var(--island-done); } + .say[data-tone='failed'] { --say: var(--island-danger); } + /* A told line is one piece: it reads at full strength. */ + .bar[data-state='told'] .say .after { + color: var(--say); + font-weight: 550; + } + .glyph, + .say-measure .glyph { + display: inline-grid; + place-items: center; + width: 14px; + height: 14px; + margin-right: 7px; vertical-align: -2px; } - .live[data-tone='waiting'] b, - .live[data-tone='amber'] b { - color: var(--island-attention); - } - .live[data-tone='done'] b { - color: var(--island-done); - } - .live[data-tone='failed'] b, - .live[data-tone='red'] b { - color: var(--island-danger); - } + .glyph[data-tone='amber'] { color: var(--island-attention); } + .glyph[data-tone='red'] { color: var(--island-danger); } + .glyph[data-tone='done'] { color: var(--island-done); } /* The timer's clock: its digits keep their width, so the pill does not twitch each second. */ - .live[data-kind='timer'] .doing, - .live-measure[data-kind='timer'] .doing { + .say .after, + .say-measure { font-variant-numeric: tabular-nums; } - .live[data-paused] .doing { + .say[data-paused] .after { opacity: 0.55; } - /* The goal: how far the task list is, a short rounded meter before its count ("2/5"), in the line. */ - .goal { - display: inline-block; - width: 28px; - height: 5px; - margin: 0 7px 0 3px; + /* The count beside the words: the task list's, or the requests'. */ + .meta { + flex: none; + color: var(--island-muted); + font-size: 12px; + font-weight: 600; + font-variant-numeric: tabular-nums; + } + .meta[data-tone='attention'] { + color: color-mix(in srgb, var(--island-attention) 80%, transparent); + } + .say-measure .meta { + margin-left: 10px; + } + /* + * The task list's progress, a hairline along the pill's lower lip, under + * the words. It moves only when the list does. + */ + .lip { + position: absolute; + right: 18px; + bottom: 3px; + left: 50px; + height: 2px; overflow: hidden; border-radius: 999px; background: var(--island-line-strong); - vertical-align: 1px; + pointer-events: none; } - .goal i { + .lip i { display: block; height: 100%; border-radius: inherit; background: var(--island-working); - transition: width 400ms ease; - } - .live[data-tone='waiting'] .goal i { - background: var(--island-attention); + transform-origin: left center; + transition: transform 500ms cubic-bezier(0.22, 1, 0.36, 1); } .dots { display: flex; @@ -586,10 +663,6 @@ .hint { color: var(--island-muted); } - /* The shield a rule's answer leaves by the dots. */ - .dot.rule { - color: var(--island-done); - } /* Asleep for a moment after the request shows: seen, not yet clickable. */ .answer[aria-disabled='true'], .answer:disabled { @@ -611,12 +684,11 @@ font-weight: 500; pointer-events: none; } - .live-measure { - font-size: 12px; + .say-measure { font-weight: 400; } - .live-measure b { - font-weight: 600; + .say-measure[data-state='told'] { + font-weight: 550; } .sr { position: absolute; @@ -648,6 +720,9 @@ animation: none !important; transition: none; } + .lip i { + transition: none; + } .ask { animation: ask-fade 200ms ease-out; } diff --git a/src/components/island/TimerBubble.svelte b/src/components/island/TimerBubble.svelte index 8745157..29a28ed 100644 --- a/src/components/island/TimerBubble.svelte +++ b/src/components/island/TimerBubble.svelte @@ -165,9 +165,10 @@ stroke: var(--tone); stroke-linecap: round; } + /* The pill's type: the drop reads as its sibling. */ .time { - font-size: 13px; - font-weight: 600; + font-size: 14px; + font-weight: 650; font-variant-numeric: tabular-nums; } .paused .time, diff --git a/src/components/island/assistant/AssistantChat.svelte b/src/components/island/assistant/AssistantChat.svelte index 96c95e8..bb41c78 100644 --- a/src/components/island/assistant/AssistantChat.svelte +++ b/src/components/island/assistant/AssistantChat.svelte @@ -7,9 +7,11 @@ Enter sends, also while the agent works (a follow-up), Shift+Enter breaks the line, Escape leaves the field. While the field has the keyboard, the island stays. With no agent yet, one button leads to where one is made. + The page that holds the chat may add its own buttons beside the field + (`tools`), as the deck's chat tab does with its screen captures. --> {#snippet face(item: BarItem)} @@ -210,7 +287,13 @@ {nameOf(item)} {:else} - {nameOf(item)} + {@const Icon = DECK_TAB_ICONS[item.tab]} + + + + {/if} {/snippet} @@ -219,7 +302,7 @@ {/snippet} -
+
{:else} - {/if} @@ -395,6 +488,14 @@ {/if}
+ + +
diff --git a/src/components/island/deck/tabs/chat-tab.svelte.test.ts b/src/components/island/deck/tabs/chat-tab.svelte.test.ts new file mode 100644 index 0000000..939606f --- /dev/null +++ b/src/components/island/deck/tabs/chat-tab.svelte.test.ts @@ -0,0 +1,98 @@ +/* + * The deck's chat tab: the conversation across the deck's width, nothing + * beside it; the screen captures as two buttons in the composer, in the + * shell only; the files dropped as chips above the field. + */ +import { afterEach, expect, test } from 'vitest'; +import { flushSync, mount, unmount } from 'svelte'; +import type { Attachment } from '@boite/contracts'; +import type { Companion } from '../../../../lib/companion/companion.svelte'; +import { viewContext, type ViewContext } from '../../../../lib/island/contract'; +import { strings } from '../../../../lib/strings'; +import ChatTab from './ChatTab.svelte'; + +/** An assistant with no agent picked yet: the head, the log and the composer, and nothing to talk to. */ +class FakeAssistant { + agent = null; + agents = []; + machine = null; + placed = null; + snapshot = null; + bubbles = []; + draft = $state(''); + running = false; + stopping = false; + sending = false; + problem = ''; + failure = ''; + tool = null; + hasOlder = false; + loadingOlder = false; + coreId = null; +} + +class FakeCompanion { + assistant = new FakeAssistant(); + attached = $state({ held: [] as Attachment[], reading: false, problem: '', over: false, remove: () => {} }); + shot = null; + captured: boolean[] = []; + + async capture(zone: boolean): Promise { + this.captured.push(zone); + return true; + } +} + +let shown: { tab: Record; target: HTMLElement } | null = null; + +function show(companion: FakeCompanion): HTMLElement { + const target = document.body.appendChild(document.createElement('div')); + const context = { companion: companion as unknown as Companion, hold: () => {} } as unknown as ViewContext; + shown = { tab: mount(ChatTab, { target, context: viewContext(context) }), target }; + flushSync(); + return target; +} + +afterEach(() => { + if (shown) { + unmount(shown.tab); + shown.target.remove(); + shown = null; + } + delete (window as { __TAURI_INTERNALS__?: unknown }).__TAURI_INTERNALS__; +}); + +test('the conversation is alone in the tab: no drop zone waits beside it', () => { + const target = show(new FakeCompanion()); + expect(target.querySelector('[data-testid=island-chat]')).not.toBeNull(); + expect(target.querySelector('[data-testid=island-drop]')).toBeNull(); + expect(target.textContent).not.toContain(strings.island.drop.title); +}); + +test('in the shell, the composer holds the two capture buttons, the screen and a zone', () => { + (window as { __TAURI_INTERNALS__?: unknown }).__TAURI_INTERNALS__ = {}; + const companion = new FakeCompanion(); + const target = show(companion); + const composer = target.querySelector('.composer')!; + const screen = composer.querySelector('[data-testid=island-capture]')!; + const zone = composer.querySelector('[data-testid=island-capture-zone]')!; + expect(screen.getAttribute('aria-label')).toBe(strings.island.drop.capture); + expect(zone.getAttribute('aria-label')).toBe(strings.island.drop.zone); + screen.click(); + zone.click(); + expect(companion.captured).toEqual([false, true]); +}); + +test('out of the shell, no capture button shows', () => { + const target = show(new FakeCompanion()); + expect(target.querySelector('[data-testid=island-capture]')).toBeNull(); +}); + +test('the files dropped show as chips above the field, each to take back', () => { + const companion = new FakeCompanion(); + companion.attached.held = [{ kind: 'file', mimeType: 'text/plain', data: 'ZGVtbw==', name: 'night.log' } as Attachment]; + const target = show(companion); + const chips = target.querySelector('.composer [data-testid=companion-attachments]'); + expect(chips?.textContent).toContain('night.log'); + expect(chips?.querySelector(`[aria-label="${strings.companion.drop.remove.replace('{name}', 'night.log')}"]`)).not.toBeNull(); +}); diff --git a/src/components/island/views/TodoCard.svelte b/src/components/island/views/TodoCard.svelte index 47571d1..73be013 100644 --- a/src/components/island/views/TodoCard.svelte +++ b/src/components/island/views/TodoCard.svelte @@ -12,6 +12,13 @@ import { todoCount } from '../../../lib/companion/todo'; import { fill, strings } from '../../../lib/strings'; + interface Props { + /** The list scrolls inside a deck tile with no bar, its edges fading (`deck-scroll`); its height is the tile's to set. */ + tile?: boolean; + } + + let { tile = false }: Props = $props(); + const view = useView(); const todos = $derived(view.companion.todos); const copy = strings.companion.todo; @@ -51,7 +58,7 @@ {#if items.length > 0} -
    +
      {#each items as item (item.id)} {@const done = item.doneAt !== null}
    • diff --git a/src/lib/island/assistant-pose.ts b/src/lib/island/assistant-pose.ts index 13b5082..00c279e 100644 --- a/src/lib/island/assistant-pose.ts +++ b/src/lib/island/assistant-pose.ts @@ -4,6 +4,7 @@ * * - `running`: an answer streaming in, a tool at work and Stop showing; * - `chips`: two files waiting for the next message; + * - `dragging`: files held over the deck, where they go showing on top of the tab; * - `menu` / `agents`: the model picker open (on the favorites, as Boite * opens it while some are starred), or the agents menu; * - `models`: the picker on the agent's provider, its models listed; @@ -61,6 +62,8 @@ export async function strikeAssistantPose(companion: Companion, params: URLSearc const assistant = companion.assistant; await until(() => assistant.agent !== null && assistant.bubbles.length > 0); if (scenes.has('chips')) companion.attached.held = [file('night.log', 'text/plain'), file('retries.png', 'image/png')]; + // No `dragover` comes to end it: the drag stays until the page is left. + if (scenes.has('dragging')) companion.attached.over = true; if (scenes.has('running')) { assistant.sending = true; assistant.stream = [streaming()]; diff --git a/src/lib/island/bar.test.ts b/src/lib/island/bar.test.ts index d751bed..bf59cda 100644 --- a/src/lib/island/bar.test.ts +++ b/src/lib/island/bar.test.ts @@ -8,8 +8,11 @@ import { BAR_MAX, BAR_STORAGE_KEY, barTabs, + barTip, canRemove, cleanLabel, + nameShown, + tipLeft, DEFAULT_BAR, hiddenTabs, hiddenTools, @@ -169,3 +172,33 @@ test('the edit mode is asked for and ended through one key both windows hear', ( expect(heard).toEqual([true, false, false]); stop(); }); + +test('the tab shown, a named app and a link show their name; other tabs and apps are their icon', () => { + const tab = { kind: 'tab', key: 'tab:chat', tab: 'chat', label: null } as const; + const renamed = { kind: 'tab', key: 'tab:today', tab: 'today', label: 'Jour' } as const; + const app = { kind: 'app', key: 'app:a', app: 'a', name: 'Spotify', label: null } as const; + const link = { kind: 'link', key: 'link:x', url: 'https://github.com/', label: null } as const; + expect(nameShown(tab, 'chat')).toBe(true); + expect(nameShown(tab, 'threads')).toBe(false); + expect(nameShown(renamed, 'threads')).toBe(false); + expect(nameShown(app, 'chat')).toBe(false); + expect(nameShown({ ...app, label: 'Music' }, 'chat')).toBe(true); + expect(nameShown(link, 'chat')).toBe(true); +}); + +test('the tooltip names what shows as an icon, and in edit mode names everything with how to edit it', () => { + const tab = { kind: 'tab', key: 'tab:system', tab: 'system', label: null } as const; + const link = { kind: 'link', key: 'link:x', url: 'https://github.com/', label: null } as const; + expect(barTip(tab, 'threads', false)).toBe('name'); + expect(barTip(tab, 'system', false)).toBeNull(); + expect(barTip(link, 'threads', false)).toBeNull(); + expect(barTip(tab, 'system', true)).toBe('edit'); + expect(barTip(link, 'threads', true)).toBe('edit'); +}); + +test('a tooltip sits centred under its thing, kept inside the bar', () => { + expect(tipLeft(400, 80, 860)).toBe(360); + expect(tipLeft(20, 80, 860)).toBe(6); + expect(tipLeft(850, 80, 860)).toBe(774); + expect(tipLeft(400, 900, 860)).toBe(6); +}); diff --git a/src/lib/island/bar.ts b/src/lib/island/bar.ts index 8467743..d1bf64d 100644 --- a/src/lib/island/bar.ts +++ b/src/lib/island/bar.ts @@ -126,6 +126,30 @@ export function parseBar(raw: unknown): BarLayout { /** The tabs the bar shows, in its order. */ export const barTabs = (layout: BarLayout): DeckTab[] => layout.items.flatMap((item) => (item.kind === 'tab' ? [item.tab] : [])); +/** + * Whether a thing shows its name beside its icon: the tab the deck shows, + * an app the user named, a link always. The other tabs and apps are their + * icon alone, and say their name in a tooltip (`barTip`). + */ +export function nameShown(item: BarItem, shown: DeckTab): boolean { + if (item.kind === 'tab') return item.tab === shown; + if (item.kind === 'app') return item.label !== null; + return true; +} + +/** What a thing's tooltip says: in edit mode, its name and how to edit it; else its name when only its icon shows; nothing when its name shows already. */ +export type BarTip = 'name' | 'edit' | null; + +export function barTip(item: BarItem, shown: DeckTab, editing: boolean): BarTip { + if (editing) return 'edit'; + return nameShown(item, shown) ? null : 'name'; +} + +/** The left edge of a tooltip `width` wide centred under `centre`, kept `margin` inside a bar `room` wide. */ +export function tipLeft(centre: number, width: number, room: number, margin = 6): number { + return Math.max(margin, Math.min(centre - width / 2, room - width - margin)); +} + /** The tabs left out, in the deck's order. */ export function hiddenTabs(layout: BarLayout): DeckTab[] { const shown = new Set(barTabs(layout)); diff --git a/src/lib/island/deck-tabs.test.ts b/src/lib/island/deck-tabs.test.ts index da1145c..36e129c 100644 --- a/src/lib/island/deck-tabs.test.ts +++ b/src/lib/island/deck-tabs.test.ts @@ -1,5 +1,5 @@ import { expect, test } from 'vitest'; -import { DECK_TAB_KEY, DECK_TABS, FIRST_TAB, readDeckTab, tabKey, travel, writeDeckTab } from './deck-tabs'; +import { DECK_TAB_ICONS, DECK_TAB_KEY, DECK_TABS, FIRST_TAB, readDeckTab, tabKey, travel, writeDeckTab } from './deck-tabs'; function memory(seed: Record = {}) { const items = new Map(Object.entries(seed)); @@ -45,3 +45,9 @@ test('with the bar arranged by hand, the arrows and the slide follow its order', expect(travel('music', 'chat', order)).toBe(-1); expect(travel('chat', 'system', order)).toBe(0); }); + +test('every tab has an icon of its own', () => { + const icons = DECK_TABS.map((tab) => DECK_TAB_ICONS[tab]); + expect(icons.every(Boolean)).toBe(true); + expect(new Set(icons).size).toBe(DECK_TABS.length); +}); diff --git a/src/lib/island/deck-tabs.ts b/src/lib/island/deck-tabs.ts index 64430c4..0594de5 100644 --- a/src/lib/island/deck-tabs.ts +++ b/src/lib/island/deck-tabs.ts @@ -8,11 +8,29 @@ * is a tablist whose focus moves with the arrows, Home and End (`tabKey`). * The user may leave tabs out of the bar and reorder them (`bar.ts`): both * then follow the bar's order, `order`, which is the deck's by default. + * + * The bar shows each tab as its icon (`DECK_TAB_ICONS`); only the tab shown + * adds its name, and the others say theirs on hover and focus. */ +import { Calendar1, ChartColumn, Clipboard, Cpu, LayoutGrid, MessageCircle, Music, Spool, Wallpaper } from '@lucide/svelte'; + export const DECK_TABS = ['threads', 'today', 'chat', 'stats', 'music', 'apps', 'clipboard', 'wallpaper', 'system'] as const; export type DeckTab = (typeof DECK_TABS)[number]; +/** Each tab's icon in the bar: a spool for the threads, the calendar's first day for today, a chip for the computer's own numbers. */ +export const DECK_TAB_ICONS: Record = { + threads: Spool, + today: Calendar1, + chat: MessageCircle, + stats: ChartColumn, + music: Music, + apps: LayoutGrid, + clipboard: Clipboard, + wallpaper: Wallpaper, + system: Cpu +}; + export const isDeckTab = (value: unknown): value is DeckTab => (DECK_TABS as readonly unknown[]).includes(value); /** Where the last tab used is kept. */ diff --git a/src/lib/island/pill-view.test.ts b/src/lib/island/pill-view.test.ts new file mode 100644 index 0000000..e09807b --- /dev/null +++ b/src/lib/island/pill-view.test.ts @@ -0,0 +1,74 @@ +import { expect, test } from 'vitest'; +import { fill, strings } from '../strings'; +import type { RestLine } from './pill-rest'; +import { emphasis, fitWords, pillText, pillView, TITLE_MAX, type PillFacts } from './pill-view'; + +const copy = strings.island.pill; +const calm: PillFacts = { waiting: { count: 0, from: null }, told: null, rule: null, rest: null, robot: false, focus: null, working: { count: 0, first: null }, goal: null }; +const busy: PillFacts = { ...calm, working: { count: 3, first: 'Bench against legacy' }, goal: { done: 2, total: 5 } }; +const quota: RestLine = { name: 'Max', doing: '18 % left', progress: null, kind: 'quota', tone: 'amber' }; + +test('someone waiting comes before everything else, in amber, the asking thread named', () => { + const view = pillView({ ...busy, waiting: { count: 1, from: 'Atlas' }, told: { text: 'Port the scheduler · done', tone: 'done' }, rest: quota }); + expect(view).toMatchObject({ state: 'waiting', tone: 'attention', strong: 'Atlas', meta: null, progress: null }); + expect(pillText(view)).toBe(fill(copy.waitsOne, { name: 'Atlas' })); + expect(pillView({ ...calm, waiting: { count: 1, from: null } }).strong).toBe(copy.someone); +}); + +test('several waiting are counted', () => { + const view = pillView({ ...busy, waiting: { count: 3, from: 'Atlas' } }); + expect(view).toMatchObject({ state: 'waiting', tone: 'attention', strong: '3' }); + expect(pillText(view)).toBe(fill(copy.waitsMany, { count: '3' })); +}); + +test('a thread just ended is told next, green or red, then what a rule allowed with its shield', () => { + expect(pillView({ ...busy, told: { text: 'Port the scheduler · done', tone: 'done' }, rule: 'Allowed by a rule', rest: quota })).toMatchObject({ + state: 'told', + tone: 'done', + glyph: null, + after: 'Port the scheduler · done' + }); + expect(pillView({ ...calm, told: { text: 'Tidy the README · failed', tone: 'failed' } }).tone).toBe('failed'); + expect(pillView({ ...busy, rule: 'Allowed by a rule · Bash · bun run test' })).toMatchObject({ state: 'told', tone: 'done', glyph: 'rule' }); +}); + +test('at rest, what Settings chose comes before the agents at work, its mark tinted and not its words', () => { + expect(pillView({ ...busy, rest: quota })).toMatchObject({ state: 'rest', tone: 'plain', glyph: 'quota', glyphTone: 'amber', strong: 'Max', after: ' 18 % left', progress: null }); + const timer: RestLine = { name: 'Work', doing: '24:59', progress: null, kind: 'timer', tone: 'amber', paused: true }; + expect(pillView({ ...busy, rest: timer })).toMatchObject({ glyph: 'timer', paused: true }); + expect(pillView({ ...busy, robot: true })).toMatchObject({ state: 'idle', strong: '', after: '' }); +}); + +test('agents at work are counted, with the task list followed as a count and a hairline', () => { + const view = pillView(busy); + expect(view).toMatchObject({ state: 'working', tone: 'plain', strong: '3', meta: '2/5', progress: 0.4 }); + expect(pillText(view)).toBe(fill(copy.workingMany, { count: '3' })); + expect(pillView({ ...busy, goal: null })).toMatchObject({ meta: null, progress: null }); + expect(pillText(pillView({ ...calm, working: { count: 1, first: 'Bench against legacy' } }))).toBe(fill(copy.workingOne, { name: 'Bench against legacy' })); +}); + +test('a thread followed or picked speaks alone: at work its goal, ended its state', () => { + const focus: RestLine = { name: 'notes', doing: '2/5 Read queue.test.ts', progress: 0.4, kind: 'live', tone: 'working' }; + expect(pillView({ ...busy, focus })).toMatchObject({ state: 'working', strong: 'notes', meta: '2/5', progress: 0.4 }); + expect(pillView({ ...busy, focus: { ...focus, doing: 'Done', progress: null, tone: 'done' } })).toMatchObject({ tone: 'plain', strong: 'notes', after: ' · done', meta: null, progress: null }); + expect(pillView({ ...busy, focus: { ...focus, doing: 'Failed', tone: 'failed' } }).tone).toBe('failed'); +}); + +test('with nothing going on, the robot alone', () => { + expect(pillView(calm)).toMatchObject({ state: 'idle', strong: '', after: '', meta: null, progress: null }); +}); + +test('long titles are cut on a word, never in one', () => { + expect(fitWords('Port the scheduler', 28)).toBe('Port the scheduler'); + expect(fitWords('Write the bench runner inside the project', 28)).toBe('Write the bench runner…'); + expect(fitWords('Fix it: the login', 9)).toBe('Fix it…'); + expect(fitWords('Supercalifragilistic', 8)).toBe('Superca…'); + const named = pillView({ ...calm, waiting: { count: 1, from: 'Bench against legacy and the new parser' } }).strong; + expect(named.length).toBeLessThanOrEqual(TITLE_MAX + 1); + expect(named.endsWith('…')).toBe(true); +}); + +test('a template is split around its value', () => { + expect(emphasis('{name} is waiting for you', 'name', 'Atlas')).toEqual({ before: '', strong: 'Atlas', after: ' is waiting for you' }); + expect(emphasis('Wait for {name}', 'name', 'Atlas')).toEqual({ before: 'Wait for ', strong: 'Atlas', after: '' }); +}); diff --git a/src/lib/island/pill-view.ts b/src/lib/island/pill-view.ts new file mode 100644 index 0000000..e74e48d --- /dev/null +++ b/src/lib/island/pill-view.ts @@ -0,0 +1,129 @@ +/* + * What the collapsed pill says, one thing at a time, in this order: + * + * 1. Someone waits on the user (a permission, a question): amber, the thread + * that asks ("Atlas attend votre réponse"), or how many wait. + * 2. Something was just told: a thread ended, a pull request's CI settled + * (green, red when it failed), or a rule answered a request (its shield). + * 3. What Settings shows at rest instead of the agents: the quota, the music, + * the timer (`pill-rest.ts`), or the robot alone. + * 4. Agents at work: how many ("3 au travail"), or the one followed from Home + * or picked with the wheel, with a task list's progress when one has it. + * 5. Nothing: the robot alone, small and quiet. + * + * The words never stop mid-word: long titles are cut on a word (`fitWords`). + * The per-thread detail (each thread's state, its step) is the hover's + * (`IslandBar.svelte`). Pure, so the order is tested without the island. + */ +import { fill, strings } from '../strings'; +import type { RestLine } from './pill-rest'; + +export type PillState = 'waiting' | 'told' | 'rest' | 'working' | 'idle'; +/** The words' colour: amber only while someone waits on the user. */ +export type PillTone = 'attention' | 'done' | 'failed' | 'plain'; +/** The mark before the words, tinted on its own (`glyphTone`). */ +export type PillGlyph = 'rule' | 'quota' | 'music' | 'timer' | null; + +export interface PillView { + state: PillState; + tone: PillTone; + glyph: PillGlyph; + /** The mark's tint: the quota's level, the timer's phase. */ + glyphTone: RestLine['tone']; + /** The words, the part set in bold apart: `before`, `strong`, `after`. */ + before: string; + strong: string; + after: string; + /** A short count at the right: the task list's ("2/5") or the requests' ("1 sur 2"). */ + meta: string | null; + /** The hairline under the words, 0 to 1; null for none. */ + progress: number | null; + /** A timer stopped for now. */ + paused: boolean; +} + +export interface PillFacts { + /** Requests that stop an agent, and the title of the thread the first one comes from (null when unknown). */ + waiting: { count: number; from: string | null }; + /** A thread that ended or a pull request's CI, while the island flashes for it. */ + told: { text: string; tone: 'done' | 'failed' } | null; + /** What a rule just allowed, said in full. */ + rule: string | null; + /** The quota, the music or the timer, when Settings shows one at rest and it has something to show. */ + rest: RestLine | null; + /** Settings shows the robot alone at rest. */ + robot: boolean; + /** The thread followed from Home or picked with the wheel: its own line (`sessionLine`). */ + focus: RestLine | null; + /** The conversations at work, and the first one's name for when it is alone. */ + working: { count: number; first: string | null }; + /** The task list the pill follows: the focused thread's, else the first at work that keeps one. */ + goal: { done: number; total: number } | null; +} + +/** The longest title the pill names before cutting it on a word. */ +export const TITLE_MAX = 28; +/** The longest told line. */ +export const TOLD_MAX = 48; + +/** `text` in at most `max` characters, cut after a whole word with an ellipsis; a lone word too long is cut in it, as a last resort. */ +export function fitWords(text: string, max: number): string { + const clean = text.trim().replace(/\s+/g, ' '); + if (clean.length <= max) return clean; + const room = clean.slice(0, max); + const space = room.lastIndexOf(' '); + const kept = space > 0 ? room.slice(0, space) : room.slice(0, max - 1); + return `${kept.replace(/[\s·:;,.–-]+$/, '')}…`; +} + +/** A template's words around its one placeholder, the value set apart to be bold. */ +export function emphasis(template: string, key: string, value: string): Pick { + const mark = `{${key}}`; + const at = template.indexOf(mark); + if (at < 0) return { before: '', strong: '', after: template }; + return { before: template.slice(0, at), strong: value, after: template.slice(at + mark.length) }; +} + +/** The whole line, as a screen reader and the roll's key read it. */ +export function pillText(view: Pick): string { + return `${view.before}${view.strong}${view.after}`.trim(); +} + +const QUIET: PillView = { state: 'idle', tone: 'plain', glyph: null, glyphTone: null, before: '', strong: '', after: '', meta: null, progress: null, paused: false }; + +function goalOf(goal: PillFacts['goal']): Pick { + if (!goal || goal.total <= 0) return { meta: null, progress: null }; + return { meta: `${goal.done}/${goal.total}`, progress: Math.min(1, Math.max(0, goal.done / goal.total)) }; +} + +/** What the pill says at rest; `state: 'idle'` with no words is the robot alone. */ +export function pillView(facts: PillFacts): PillView { + const copy = strings.island.pill; + const { waiting } = facts; + if (waiting.count > 0) { + const words = + waiting.count > 1 + ? emphasis(copy.waitsMany, 'count', String(waiting.count)) + : emphasis(copy.waitsOne, 'name', waiting.from ? fitWords(waiting.from, TITLE_MAX) : copy.someone); + return { ...QUIET, state: 'waiting', tone: 'attention', ...words }; + } + if (facts.told) return { ...QUIET, state: 'told', tone: facts.told.tone, after: fitWords(facts.told.text, TOLD_MAX) }; + if (facts.rule) return { ...QUIET, state: 'told', tone: 'done', glyph: 'rule', after: fitWords(facts.rule, TOLD_MAX) }; + if (facts.robot) return QUIET; + if (facts.rest) { + const line = facts.rest; + const glyph = line.kind === 'live' ? null : line.kind; + return { ...QUIET, state: 'rest', glyph, glyphTone: line.tone, strong: fitWords(line.name, TITLE_MAX), after: line.doing ? ` ${line.doing}` : '', paused: line.paused ?? false }; + } + if (facts.focus) { + const line = facts.focus; + const name = fitWords(line.name, TITLE_MAX); + // Ended, its state follows its name ("notes · terminé"); at work, its task list's progress. + if (line.tone === 'done' || line.tone === 'failed') return { ...QUIET, state: 'working', tone: line.tone === 'failed' ? 'failed' : 'plain', strong: name, after: ` · ${line.doing.toLocaleLowerCase()}` }; + return { ...QUIET, state: 'working', ...emphasis(copy.workingOne, 'name', name), ...goalOf(facts.goal) }; + } + const { count, first } = facts.working; + if (count <= 0) return QUIET; + const words = count === 1 && first ? emphasis(copy.workingOne, 'name', fitWords(first, TITLE_MAX)) : emphasis(copy.workingMany, 'count', String(count)); + return { ...QUIET, state: 'working', ...words, ...goalOf(facts.goal) }; +} diff --git a/src/lib/island/pose.ts b/src/lib/island/pose.ts index 106c934..943ee47 100644 --- a/src/lib/island/pose.ts +++ b/src/lib/island/pose.ts @@ -30,8 +30,8 @@ * - `memory=thread|budget|machine|stopped`: the memory warning, or what the * guard stopped; * - `update=nightly|stable`: a newer Boite in Home; - * - `sessions=none`: Home's sessions shown empty, the robot napping (pair it - * with `calm=1`); + * - `sessions=none`: Home's sessions shown empty, and the pill with no agent + * at work, the robot napping (pair it with `calm=1`); * - `rest=live|quota|music|timer|robot`: what the pill shows at rest (pair `music` * with `music=playing`, `timer` with `timer=1`). A thread followed in the pill is chosen through * `localStorage` (`bots.followed`, `followed.ts`) before the page loads. diff --git a/src/lib/strings.en.ts b/src/lib/strings.en.ts index e223b49..49e5c84 100644 --- a/src/lib/strings.en.ts +++ b/src/lib/strings.en.ts @@ -59,6 +59,8 @@ export const strings = { waitingOne: 'An agent needs you', waitingMany: '{count} requests wait for you', finished: 'Done', + /** The collapsed pill's one line (`pill-view.ts`): its `{name}` or `{count}` is set in bold. */ + pill: { waitsOne: '{name} is waiting for you', waitsMany: '{count} are waiting for you', someone: 'An agent', workingOne: '{name} is working', workingMany: '{count} working' }, /** A thread that just ended, told in the pill: "Port the scheduler · done · 3 files". */ finish: { done: 'done', @@ -458,6 +460,7 @@ export const strings = { slow: 'Why is it slow?', slowIntro: 'My computer feels slow. Here is what it shows right now. What is slowing it down, and what can I do about it?', unavailable: 'The readings are not available.', + lastMinute: 'Last minute: {low} to {high} %', failed: 'That did not work.', point: '.' } diff --git a/src/lib/strings.fr.ts b/src/lib/strings.fr.ts index 9a2e64e..d81cf3a 100644 --- a/src/lib/strings.fr.ts +++ b/src/lib/strings.fr.ts @@ -49,6 +49,7 @@ export const fr: Messages = { waitingOne: 'Un agent a besoin de vous', waitingMany: '{count} demandes vous attendent', finished: 'Terminé', + pill: { waitsOne: '{name} attend votre réponse', waitsMany: '{count} vous attendent', someone: 'Un agent', workingOne: '{name} au travail', workingMany: '{count} au travail' }, finish: { done: 'terminé', failed: 'en échec', @@ -433,6 +434,7 @@ export const fr: Messages = { slow: 'Pourquoi c’est lent ?', slowIntro: 'Mon ordinateur rame. Voici ce qu’il affiche en ce moment. Qu’est-ce qui le ralentit, et que puis-je faire ?', unavailable: 'Les mesures ne sont pas disponibles.', + lastMinute: 'Dernière minute : de {low} à {high} %', failed: 'Ça n’a pas marché.', point: ',' } diff --git a/src/lib/windows/history.test.ts b/src/lib/windows/history.test.ts new file mode 100644 index 0000000..a9f0e4f --- /dev/null +++ b/src/lib/windows/history.test.ts @@ -0,0 +1,44 @@ +import { describe, expect, it } from 'vitest'; +import { HISTORY_MS, remember, sparkPaths, spanOf, type Reading } from './history'; + +const T0 = Date.UTC(2026, 9, 13, 15, 0, 0); +const at = (seconds: number, value: number): Reading => ({ at: T0 + seconds * 1_000, value }); + +describe('the last minute of a reading', () => { + it('keeps a minute, and one reading past its edge', () => { + let history: Reading[] = []; + for (let second = 0; second <= 80; second += 2) history = remember(history, at(second, second)); + expect(history[history.length - 1]).toEqual(at(80, 80)); + // 80 s - 60 s = 20 s: the reading at 18 s stays, so the line reaches the left edge. + expect(history[0]).toEqual(at(18, 18)); + expect(history).toHaveLength(32); + }); + + it('clamps a reading to 0..100, and an earlier reading replaces the ones after it', () => { + const history = remember([at(10, 30)], at(12, 140)); + expect(history[1]!.value).toBe(100); + expect(remember(history, at(11, 5))).toEqual([at(10, 30), at(11, 5)]); + expect(remember([], at(0, -4))).toEqual([at(0, 0)]); + }); + + it('draws nothing under two readings', () => { + expect(sparkPaths([], 300, 40)).toEqual({ line: '', area: '' }); + expect(sparkPaths([at(0, 50)], 300, 40)).toEqual({ line: '', area: '' }); + }); + + it('draws the newest reading on the right and a minute ago on the left', () => { + const { line, area } = sparkPaths([at(0, 0), at(30, 50), at(60, 100)], 300, 40); + expect(line).toBe('M0,40L150,20L300,0'); + expect(area).toBe('M0,40L150,20L300,0L300,40L0,40Z'); + }); + + it('starts a short history on the right, and pins what lies past a minute to the left edge', () => { + expect(sparkPaths([at(50, 20), at(60, 20)], 300, 40).line).toBe('M250,32L300,32'); + expect(sparkPaths([at(-10, 20), at(60, 20)], 300, 40, HISTORY_MS).line).toBe('M0,32L300,32'); + }); + + it('reads the lowest and the highest', () => { + expect(spanOf([])).toBeNull(); + expect(spanOf([at(0, 31.4), at(2, 18.6), at(4, 40.5)])).toEqual({ low: 19, high: 41 }); + }); +}); diff --git a/src/lib/windows/history.ts b/src/lib/windows/history.ts new file mode 100644 index 0000000..3541d7c --- /dev/null +++ b/src/lib/windows/history.ts @@ -0,0 +1,56 @@ +/* + * The system tab's last minute of a reading (the processor's load), drawn + * under its number: the readings it keeps, and the line and the area that + * draw them. The tab keeps them while it shows and drops them as it closes. + */ + +export interface Reading { + /** When it was read, in milliseconds. */ + at: number; + /** From 0 to 100. */ + value: number; +} + +/** How far back the drawing reaches. */ +export const HISTORY_MS = 60_000; + +/** `history` with `reading` added, without what fell out of the last `span` (kept one past the edge, so the line reaches it). */ +export function remember(history: readonly Reading[], reading: Reading, span = HISTORY_MS): Reading[] { + const value = Math.min(100, Math.max(0, reading.value)); + const kept = [...history.filter((point) => point.at < reading.at), { at: reading.at, value }]; + const edge = reading.at - span; + const first = kept.findIndex((point) => point.at >= edge); + return kept.slice(Math.max(0, first - 1)); +} + +export interface Spark { + /** The line through the readings. */ + line: string; + /** The same line closed down to the bottom, for the area under it. */ + area: string; +} + +/** + * The paths for `history` in a `width` × `height` box: the newest reading on + * the right edge, a minute ago on the left, 0 at the bottom and 100 at the + * top. Empty under two readings, there is no line yet. + */ +export function sparkPaths(history: readonly Reading[], width: number, height: number, span = HISTORY_MS): Spark { + if (history.length < 2) return { line: '', area: '' }; + const newest = history[history.length - 1]!.at; + const x = (at: number) => round(Math.max(0, width - ((newest - at) / span) * width)); + const y = (value: number) => round(height - (value / 100) * height); + const points = history.map((point) => `${x(point.at)},${y(point.value)}`); + const line = `M${points.join('L')}`; + const area = `${line}L${x(newest)},${height}L${x(history[0]!.at)},${height}Z`; + return { line, area }; +} + +/** The lowest and the highest of `history`, rounded, or null when it is empty. */ +export function spanOf(history: readonly Reading[]): { low: number; high: number } | null { + if (history.length === 0) return null; + const values = history.map((point) => point.value); + return { low: Math.round(Math.min(...values)), high: Math.round(Math.max(...values)) }; +} + +const round = (value: number) => Math.round(value * 10) / 10; diff --git a/src/lib/windows/system.test.ts b/src/lib/windows/system.test.ts index ee4ef78..4bb46b1 100644 --- a/src/lib/windows/system.test.ts +++ b/src/lib/windows/system.test.ts @@ -1,11 +1,25 @@ import { describe, expect, it } from 'vitest'; import type { Proc, Sample } from './native'; -import { formatBytes, percent, processLabel, slowQuestion, topProcesses, uptimeOf } from './system'; +import { bytesParts, formatBytes, percent, processLabel, processShare, slowQuestion, topProcesses, uptimeOf } from './system'; const GB = 1024 ** 3; const proc = (name: string, cpu: number, memory: number, pid = name.length): Proc => ({ pid, name, cpu, memory, started: 1, protected: false }); describe('system readings', () => { + it('splits a size into its number and its unit', () => { + expect(bytesParts(19.6 * GB, ',')).toEqual({ value: '20', unit: 'GB' }); + expect(bytesParts(1.1 * 1024 * GB, ',')).toEqual({ value: '1,1', unit: 'TB' }); + expect(bytesParts(0)).toEqual({ value: '0', unit: 'B' }); + }); + + it("reads a process's share of the computer", () => { + expect(processShare(proc('Unity.exe', 24.2, 4 * GB), 'cpu', 32 * GB)).toBeCloseTo(0.242); + expect(processShare(proc('Unity.exe', 24.2, 4 * GB), 'memory', 32 * GB)).toBe(0.125); + expect(processShare(proc('busy.exe', 140, 0), 'cpu', 32 * GB)).toBe(1); + expect(processShare(proc('odd.exe', -3, 0), 'cpu', 32 * GB)).toBe(0); + expect(processShare(proc('none.exe', 0, GB), 'memory', 0)).toBe(0); + }); + it('formats sizes', () => { expect(formatBytes(0)).toBe('0 B'); expect(formatBytes(512 * 1024 ** 2)).toBe('512 MB'); diff --git a/src/lib/windows/system.ts b/src/lib/windows/system.ts index 70f18cc..c9ed0c6 100644 --- a/src/lib/windows/system.ts +++ b/src/lib/windows/system.ts @@ -19,6 +19,12 @@ export function formatBytes(bytes: number, decimal = '.'): string { return `${shown.replace('.', decimal)} ${UNITS[unit]}`; } +/** `formatBytes` in two parts, the number and its unit, for a number set larger than its unit. */ +export function bytesParts(bytes: number, decimal = '.'): { value: string; unit: string } { + const [value = '', unit = ''] = formatBytes(bytes, decimal).split(' '); + return { value, unit }; +} + export interface Uptime { days: number; hours: number; minutes: number } export function uptimeOf(seconds: number): Uptime { @@ -38,6 +44,12 @@ export function topProcesses(processes: readonly Proc[], sort: ProcessSort, coun /** A process's name without `.exe`. */ export const processLabel = (name: string): string => name.replace(/\.exe$/i, ''); +/** A process's share of the computer by `sort`, from 0 to 1: its load, or its memory of all the memory. */ +export function processShare(proc: Pick, sort: ProcessSort, memoryTotal: number): number { + const share = sort === 'cpu' ? proc.cpu / 100 : memoryTotal > 0 ? proc.memory / memoryTotal : 0; + return Math.min(1, Math.max(0, share)); +} + /** * The question "Why is it slow?" sends: the readings as plain lines, so the * assistant can answer from them. `intro` is the localized sentence before.