@workerdeck/ui 0.13.0 → 0.16.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +72 -0
- package/build/{SessionPanel-CZMA44NM.d.mts → SessionPanel-B9CHoq8x.d.mts} +213 -27
- package/build/{SessionPanel-CKQa4i0Y.mjs → SessionPanel-DII9MmQ8.mjs} +5192 -2542
- package/build/SessionPanel-DII9MmQ8.mjs.map +1 -0
- package/build/{format-ljc3lKpA.d.mts → format-DfI_je9S.d.mts} +1 -1
- package/build/format.d.mts +39 -4
- package/build/format.mjs +2 -118
- package/build/index.d.mts +494 -45
- package/build/index.mjs +182 -24
- package/build/index.mjs.map +1 -1
- package/build/status-Ydzi7n6j.mjs +143 -0
- package/build/status-Ydzi7n6j.mjs.map +1 -0
- package/build/workspace.d.mts +13 -1
- package/build/workspace.mjs +111 -5
- package/build/workspace.mjs.map +1 -1
- package/package.json +14 -7
- package/src/components/agent/Composer.tsx +251 -84
- package/src/components/agent/Conversation.tsx +12 -12
- package/src/components/agent/FileCard.tsx +0 -26
- package/src/components/agent/FileTree.tsx +9 -8
- package/src/components/agent/Loader.tsx +22 -72
- package/src/components/agent/Message.tsx +11 -43
- package/src/components/agent/PermissionPrompt.tsx +0 -92
- package/src/components/agent/QuestionPrompt.tsx +0 -122
- package/src/components/agent/Reasoning.tsx +5 -19
- package/src/components/agent/Response.tsx +1 -132
- package/src/components/agent/SessionBrowser.tsx +35 -25
- package/src/components/agent/SessionPanel.tsx +312 -63
- package/src/components/agent/SessionWorkspace.tsx +36 -0
- package/src/components/agent/StatusBar.tsx +70 -15
- package/src/components/agent/ToolCallCard.tsx +17 -111
- package/src/components/agent/Transcript.tsx +710 -203
- package/src/components/agent/UsageDialog.tsx +20 -106
- package/src/components/agent/UsageMeters.tsx +133 -0
- package/src/components/agent/pulse.tsx +3 -2
- package/src/components/agent/transcript-rows.ts +82 -0
- package/src/components/agent/transcript-variant.tsx +43 -51
- package/src/components/agent/use-height-epoch.ts +60 -0
- package/src/components/agent/use-path-links.ts +147 -0
- package/src/components/agent/use-transcript-jumps.ts +190 -0
- package/src/components/prompt-area/cursor-helpers.ts +65 -0
- package/src/components/prompt-area/use-prompt-area.ts +16 -10
- package/src/components/terminal/PermissionPrompt.tsx +119 -0
- package/src/components/terminal/QuestionPrompt.tsx +322 -0
- package/src/components/terminal/StatusLine.tsx +159 -0
- package/src/components/terminal/TerminalTranscript.tsx +147 -0
- package/src/components/terminal/affordances.tsx +118 -0
- package/src/components/terminal/diff.tsx +130 -0
- package/src/components/terminal/height.ts +727 -0
- package/src/components/terminal/items.tsx +449 -0
- package/src/components/terminal/markdown.tsx +191 -0
- package/src/components/terminal/press.tsx +120 -0
- package/src/components/terminal/prompt.tsx +343 -0
- package/src/components/terminal/result-preview.ts +72 -0
- package/src/components/terminal/row.tsx +132 -0
- package/src/components/terminal/scrubber.tsx +663 -0
- package/src/components/terminal/surface.tsx +80 -0
- package/src/components/terminal/tool-run.ts +91 -0
- package/src/components/ui/Badge.tsx +6 -1
- package/src/components/ui/Empty.tsx +56 -0
- package/src/components/ui/Splitter.tsx +14 -0
- package/src/index.ts +36 -0
- package/src/lib/status.ts +59 -3
- package/src/lib/tool-icon.ts +14 -0
- package/src/styles/terminal.css +1011 -0
- package/src/styles/theme.css +73 -0
- package/build/SessionPanel-CKQa4i0Y.mjs.map +0 -1
- package/build/format.mjs.map +0 -1
- package/src/components/agent/line-prompt.tsx +0 -249
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
import type { CSSProperties, HTMLAttributes } from 'react'
|
|
2
|
+
import { cn } from '../../lib/utils.ts'
|
|
3
|
+
import {
|
|
4
|
+
AffordanceProvider,
|
|
5
|
+
resolveAffordances,
|
|
6
|
+
type TerminalAffordances,
|
|
7
|
+
} from './affordances.tsx'
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* The root of the terminal theme: the element that establishes the character
|
|
11
|
+
* cell every row inside it lands on.
|
|
12
|
+
*
|
|
13
|
+
* It exists as a component rather than a class because the cell has to be *set*
|
|
14
|
+
* somewhere — the font, the size and the line height together are what make
|
|
15
|
+
* `1ch` mean one column and `--term-line` mean one row — and because that is the
|
|
16
|
+
* one place a host is allowed to change the metrics. Everything below this
|
|
17
|
+
* element measures itself in those two units and never in pixels.
|
|
18
|
+
*
|
|
19
|
+
* The geometry and the palette live in `styles/terminal.css`; this only names
|
|
20
|
+
* the element and hands down the two numbers.
|
|
21
|
+
*/
|
|
22
|
+
export interface TerminalSurfaceProps extends HTMLAttributes<HTMLDivElement> {
|
|
23
|
+
/**
|
|
24
|
+
* Cell size in **whole pixels**. Fractional values put every other row on a
|
|
25
|
+
* half-pixel: text softens and the diff bands show a seam, which is exactly
|
|
26
|
+
* what a grid renderer must not do. Defaults (13/18) are the CLI's own.
|
|
27
|
+
*/
|
|
28
|
+
fontSize?: number
|
|
29
|
+
lineHeight?: number
|
|
30
|
+
/**
|
|
31
|
+
* How far a full-bleed band reaches past the content edge — normally the
|
|
32
|
+
* scroller's own horizontal padding. A band cancels it with matched negative
|
|
33
|
+
* margin and padding, so a diff hunk's wash runs to the viewport edge the way
|
|
34
|
+
* a terminal's line does, instead of stopping at a gutter.
|
|
35
|
+
*/
|
|
36
|
+
bleed?: string
|
|
37
|
+
/**
|
|
38
|
+
* The things a real terminal cannot do — the pointer hover fill, the
|
|
39
|
+
* hover-revealed copy actions. `false` turns them all off, an object picks.
|
|
40
|
+
* Default: all on. See {@link TerminalAffordances}; none of them costs layout,
|
|
41
|
+
* so switching them off changes no glyph's position.
|
|
42
|
+
*/
|
|
43
|
+
affordances?: TerminalAffordances | boolean
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export function TerminalSurface({
|
|
47
|
+
fontSize,
|
|
48
|
+
lineHeight,
|
|
49
|
+
bleed,
|
|
50
|
+
affordances,
|
|
51
|
+
className,
|
|
52
|
+
style,
|
|
53
|
+
children,
|
|
54
|
+
...props
|
|
55
|
+
}: TerminalSurfaceProps) {
|
|
56
|
+
const resolved = resolveAffordances(affordances)
|
|
57
|
+
return (
|
|
58
|
+
<div
|
|
59
|
+
data-terminal=''
|
|
60
|
+
// A space-separated list so CSS can ask for one with `~=` — the styling
|
|
61
|
+
// half of the switch, where the JS half (whether a button exists at all)
|
|
62
|
+
// rides the context.
|
|
63
|
+
data-affordances={
|
|
64
|
+
[resolved.hover && 'hover', resolved.actions && 'actions'].filter(Boolean).join(' ') ||
|
|
65
|
+
undefined
|
|
66
|
+
}
|
|
67
|
+
className={cn('min-w-0', className)}
|
|
68
|
+
style={
|
|
69
|
+
{
|
|
70
|
+
...(fontSize !== undefined && { '--term-font-size': `${Math.round(fontSize)}px` }),
|
|
71
|
+
...(lineHeight !== undefined && { '--term-line': `${Math.round(lineHeight)}px` }),
|
|
72
|
+
...(bleed !== undefined && { '--term-bleed': bleed }),
|
|
73
|
+
...style,
|
|
74
|
+
} as CSSProperties
|
|
75
|
+
}
|
|
76
|
+
{...props}>
|
|
77
|
+
<AffordanceProvider value={resolved}>{children}</AffordanceProvider>
|
|
78
|
+
</div>
|
|
79
|
+
)
|
|
80
|
+
}
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* What a folded run of tool calls is, and the one line that stands for it.
|
|
3
|
+
*
|
|
4
|
+
* The fold was shell-only, and the screenshot that started this made the cost
|
|
5
|
+
* obvious: a run of six calls that happened to alternate `Bash` with an MCP tool
|
|
6
|
+
* folded into *four* rows reading "Ran 1 shell command", "Ran 2 shell commands",
|
|
7
|
+
* "Ran 1 shell command" — a count for every gap between the calls it could not
|
|
8
|
+
* group. The grouping rule was right and the *membership* rule was too narrow.
|
|
9
|
+
*
|
|
10
|
+
* So any consecutive tool calls fold. The CLI's own line is the target
|
|
11
|
+
* (`called roam-code, ran 1 shell command`), and the claim is unchanged from the
|
|
12
|
+
* shell version: a tool call is almost never what you came back to read, and six
|
|
13
|
+
* of them bury the sentence that is.
|
|
14
|
+
*
|
|
15
|
+
* Pure and separate because `items.tsx` draws this line and `height.ts` wraps it
|
|
16
|
+
* to predict the row's pixel height without a DOM — the same reason
|
|
17
|
+
* `result-preview.ts` exists. Two spellings would be two different heights.
|
|
18
|
+
*/
|
|
19
|
+
import type { TranscriptItem } from '@workerdeck/react'
|
|
20
|
+
import { isShellTool } from '../../lib/tool-icon.ts'
|
|
21
|
+
|
|
22
|
+
type ToolCallItem = Extract<TranscriptItem, { kind: 'tool_call' }>
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* What breaks a run.
|
|
26
|
+
*
|
|
27
|
+
* *Consecutive* is still the whole rule, and it is the reason the fold is honest:
|
|
28
|
+
* anything the model said between two calls breaks the run, because that
|
|
29
|
+
* sentence is the reason the second one happened and a count spanning it would
|
|
30
|
+
* claim the two were one act. The recap boundary breaks it too — the virtualized
|
|
31
|
+
* shell folds each side separately — so a count never spans "what you already
|
|
32
|
+
* read".
|
|
33
|
+
*
|
|
34
|
+
* `parentToolUseId` is the one addition the wider membership rule needs: a
|
|
35
|
+
* subagent's calls are drawn stepped in behind a rule, and folding one together
|
|
36
|
+
* with a top-level call would put rows from two different frames of reference
|
|
37
|
+
* under a single count.
|
|
38
|
+
*/
|
|
39
|
+
export function foldsTogether(a: ToolCallItem, b: ToolCallItem): boolean {
|
|
40
|
+
return a.parentToolUseId === b.parentToolUseId
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* The family a tool counts as in a run's breakdown.
|
|
45
|
+
*
|
|
46
|
+
* An MCP tool is `mcp__<server>__<tool>`, and the *server* is the useful unit:
|
|
47
|
+
* "3 roam-code" is a thing that happened, where three separate tool names are a
|
|
48
|
+
* list to read. Shell tools from both engines collapse to "shell" for the same
|
|
49
|
+
* reason. Everything else is its own name, lowercased so the breakdown reads as
|
|
50
|
+
* prose rather than as identifiers.
|
|
51
|
+
*/
|
|
52
|
+
export function toolFamily(name: string): string {
|
|
53
|
+
if (isShellTool(name)) return 'shell'
|
|
54
|
+
const mcp = /^mcp__([^_]+(?:_[^_]+)*?)__/.exec(name)
|
|
55
|
+
if (mcp?.[1]) return mcp[1].replace(/_/g, '-')
|
|
56
|
+
return name.toLowerCase()
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* The run's one line.
|
|
61
|
+
*
|
|
62
|
+
* A shell-only run keeps its old wording exactly — "Ran 3 shell commands" — both
|
|
63
|
+
* because it is the commonest run by far and because it is already the sentence
|
|
64
|
+
* people read here; widening the fold should not have re-worded the case that
|
|
65
|
+
* was working. Anything mixed gets the count plus a breakdown, loudest family
|
|
66
|
+
* first.
|
|
67
|
+
*/
|
|
68
|
+
export function runSummary(items: readonly ToolCallItem[], busy: boolean): string {
|
|
69
|
+
const verb = busy ? 'Running ' : 'Ran '
|
|
70
|
+
const tail = busy ? '…' : ''
|
|
71
|
+
const n = items.length
|
|
72
|
+
|
|
73
|
+
const counts = new Map<string, number>()
|
|
74
|
+
for (const item of items) {
|
|
75
|
+
const family = toolFamily(item.name)
|
|
76
|
+
counts.set(family, (counts.get(family) ?? 0) + 1)
|
|
77
|
+
}
|
|
78
|
+
if (counts.size === 1 && counts.has('shell')) {
|
|
79
|
+
return `${verb}${n} shell command${n === 1 ? '' : 's'}${tail}`
|
|
80
|
+
}
|
|
81
|
+
// Descending by count, then alphabetical — a stable order matters more than it
|
|
82
|
+
// looks: this string is the row's measured height, so a run whose breakdown
|
|
83
|
+
// reordered between renders would remeasure for no reason.
|
|
84
|
+
const breakdown = [...counts.entries()]
|
|
85
|
+
.sort((a, b) => b[1] - a[1] || a[0].localeCompare(b[0]))
|
|
86
|
+
.map(([family, count]) => `${count} ${family}`)
|
|
87
|
+
.join(', ')
|
|
88
|
+
// The ellipsis trails the whole line, not the count — "Running 2 tools… · 1
|
|
89
|
+
// read, 1 shell" reads as though the sentence ended and then carried on.
|
|
90
|
+
return `${verb}${n} tool${n === 1 ? '' : 's'} · ${breakdown}${tail}`
|
|
91
|
+
}
|
|
@@ -33,7 +33,12 @@ export interface BadgeProps
|
|
|
33
33
|
export function Badge({ className, variant = 'neutral', mono, dot, children, ...props }: BadgeProps) {
|
|
34
34
|
return (
|
|
35
35
|
<span data-slot='badge' className={cn(badgeVariants({ variant, mono, className }))} {...props}>
|
|
36
|
-
{dot
|
|
36
|
+
{/* `self-center` so the dot stays put if a caller baseline-aligns the
|
|
37
|
+
badge — a no-op under the default `items-center`, and what lets the
|
|
38
|
+
badge's *text* be the baseline it contributes to a row (a status bar
|
|
39
|
+
aligning readings on one line needs that, and the dot's own box would
|
|
40
|
+
otherwise answer for it). */}
|
|
41
|
+
{dot ? <span aria-hidden className='size-1.5 shrink-0 self-center rounded-full bg-current' /> : null}
|
|
37
42
|
{children}
|
|
38
43
|
</span>
|
|
39
44
|
)
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
import type { ReactNode } from 'react'
|
|
2
|
+
import { Button } from './Button.tsx'
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* A view with nothing in it: icon, title, description, and at most one action.
|
|
6
|
+
*
|
|
7
|
+
* Centered and given room, because an empty view is the first thing a new
|
|
8
|
+
* install shows and a flush-left sentence in the top corner reads like a bug
|
|
9
|
+
* report. It lives here rather than in one client because every panel that can
|
|
10
|
+
* be empty should be empty the same way — the extension's views and the
|
|
11
|
+
* dashboard's four sidebars are the same shape of thing.
|
|
12
|
+
*
|
|
13
|
+
* **At most one action, and never one the view header already offers.** Creating
|
|
14
|
+
* a session and adding a gateway are the `+` in the native title bar, exclusively
|
|
15
|
+
* — so those empty states point at it in words rather than growing a second
|
|
16
|
+
* button that does the same thing two inches lower. A button here is for the way
|
|
17
|
+
* out of a state the header has no answer to: clearing a filter, widening a
|
|
18
|
+
* scope.
|
|
19
|
+
*/
|
|
20
|
+
export function Empty({
|
|
21
|
+
icon,
|
|
22
|
+
title,
|
|
23
|
+
description,
|
|
24
|
+
action,
|
|
25
|
+
onAction,
|
|
26
|
+
}: {
|
|
27
|
+
icon: ReactNode
|
|
28
|
+
title: string
|
|
29
|
+
description?: ReactNode
|
|
30
|
+
action?: string
|
|
31
|
+
onAction?: () => void
|
|
32
|
+
}) {
|
|
33
|
+
return (
|
|
34
|
+
<div className='flex flex-col items-center gap-2 px-5 py-8 text-center'>
|
|
35
|
+
<div className='text-fg-4 opacity-60 [&_svg]:size-7'>{icon}</div>
|
|
36
|
+
<p className='text-body-sm font-medium text-fg-2'>{title}</p>
|
|
37
|
+
{description ? (
|
|
38
|
+
<p className='max-w-[26ch] text-balance text-label leading-relaxed text-fg-4'>
|
|
39
|
+
{description}
|
|
40
|
+
</p>
|
|
41
|
+
) : null}
|
|
42
|
+
{action && onAction ? (
|
|
43
|
+
<Button variant='outline' size='sm' className='mt-1' onClick={onAction}>
|
|
44
|
+
{action}
|
|
45
|
+
</Button>
|
|
46
|
+
) : null}
|
|
47
|
+
</div>
|
|
48
|
+
)
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/** A key or icon named inline in an empty state's description — "use the + above". */
|
|
52
|
+
export function EmptyKey({ children }: { children: ReactNode }) {
|
|
53
|
+
return (
|
|
54
|
+
<span className='rounded border border-border px-1 font-mono text-fg-3'>{children}</span>
|
|
55
|
+
)
|
|
56
|
+
}
|
|
@@ -20,6 +20,12 @@ export interface SplitterProps {
|
|
|
20
20
|
max: number
|
|
21
21
|
/** Keyboard step. */
|
|
22
22
|
step?: number
|
|
23
|
+
/**
|
|
24
|
+
* Size to snap back to on a double-click — the convention every pane divider
|
|
25
|
+
* that can be dragged is expected to honour. Omit and a double-click does
|
|
26
|
+
* nothing, which is the honest behaviour when there is no default to mean.
|
|
27
|
+
*/
|
|
28
|
+
defaultValue?: number
|
|
23
29
|
/** Set when dragging the splitter *away* from the origin should shrink the
|
|
24
30
|
* controlled pane — i.e. the pane is on the right or the bottom. */
|
|
25
31
|
inverted?: boolean
|
|
@@ -52,6 +58,7 @@ export function Splitter({
|
|
|
52
58
|
min,
|
|
53
59
|
max,
|
|
54
60
|
step = 16,
|
|
61
|
+
defaultValue,
|
|
55
62
|
inverted,
|
|
56
63
|
'aria-label': label,
|
|
57
64
|
className,
|
|
@@ -84,6 +91,12 @@ export function Splitter({
|
|
|
84
91
|
}
|
|
85
92
|
}
|
|
86
93
|
|
|
94
|
+
// The second click of a double-click has already started a drag, so the reset
|
|
95
|
+
// has to land after `endDrag` — which it does: dblclick fires after pointerup.
|
|
96
|
+
const onDoubleClick = () => {
|
|
97
|
+
if (defaultValue !== undefined) onValueChange(clamp(defaultValue))
|
|
98
|
+
}
|
|
99
|
+
|
|
87
100
|
const onKeyDown = (event: ReactKeyboardEvent<HTMLDivElement>) => {
|
|
88
101
|
// The arrows that move *along* this splitter's axis of travel; the other
|
|
89
102
|
// pair is left to the page, which is what a separator should do with them.
|
|
@@ -112,6 +125,7 @@ export function Splitter({
|
|
|
112
125
|
onPointerMove={onPointerMove}
|
|
113
126
|
onPointerUp={endDrag}
|
|
114
127
|
onPointerCancel={endDrag}
|
|
128
|
+
onDoubleClick={onDoubleClick}
|
|
115
129
|
onKeyDown={onKeyDown}
|
|
116
130
|
className={cn(
|
|
117
131
|
// A 1px line that reads as a border, with a larger invisible grab area
|
package/src/index.ts
CHANGED
|
@@ -42,6 +42,7 @@ export { CopyButton, type CopyButtonProps } from './components/ui/CopyButton.tsx
|
|
|
42
42
|
export { Spinner } from './components/ui/Spinner.tsx'
|
|
43
43
|
export { CodeBlock, type CodeBlockProps } from './components/ui/CodeBlock.tsx'
|
|
44
44
|
export { Splitter, type SplitterProps } from './components/ui/Splitter.tsx'
|
|
45
|
+
export { Empty, EmptyKey } from './components/ui/Empty.tsx'
|
|
45
46
|
export { ProgressRing, type ProgressRingProps } from './components/ui/ProgressRing.tsx'
|
|
46
47
|
// Prompt input (vendored just-marketing/prompt-area, themed to these tokens)
|
|
47
48
|
export {
|
|
@@ -70,11 +71,44 @@ export {
|
|
|
70
71
|
type SessionPanelProps,
|
|
71
72
|
type SessionSurfacePanel,
|
|
72
73
|
type SessionVitals,
|
|
74
|
+
type TerminalMetrics,
|
|
73
75
|
} from './components/agent/SessionPanel.tsx'
|
|
74
76
|
// The workspace layout and its Monaco editor live at `@workerdeck/ui/workspace`
|
|
75
77
|
// — deliberately unreachable from here, so importing this entry never drags
|
|
76
78
|
// Monaco into the bundle. See `src/workspace.ts`.
|
|
77
79
|
export { Transcript, type TranscriptProps } from './components/agent/Transcript.tsx'
|
|
80
|
+
// The terminal theme. `SessionPanel`/`Transcript` reach it through
|
|
81
|
+
// `variant: 'terminal'` and most embedders need nothing else — these are for a
|
|
82
|
+
// host composing the surface by hand (a prompt outside the panel, a status line
|
|
83
|
+
// in its own chrome) and for the primitives, so a host's own row lands on the
|
|
84
|
+
// same character cell as ours.
|
|
85
|
+
export {
|
|
86
|
+
TerminalSurface,
|
|
87
|
+
type TerminalSurfaceProps,
|
|
88
|
+
} from './components/terminal/surface.tsx'
|
|
89
|
+
export {
|
|
90
|
+
TerminalTranscript,
|
|
91
|
+
TerminalItemView,
|
|
92
|
+
type TerminalTranscriptProps,
|
|
93
|
+
} from './components/terminal/TerminalTranscript.tsx'
|
|
94
|
+
export { TerminalStatusLine, type TerminalStatusLineProps } from './components/terminal/StatusLine.tsx'
|
|
95
|
+
export {
|
|
96
|
+
TerminalPermissionPrompt,
|
|
97
|
+
type TerminalPermissionPromptProps,
|
|
98
|
+
} from './components/terminal/PermissionPrompt.tsx'
|
|
99
|
+
export {
|
|
100
|
+
TerminalQuestionPrompt,
|
|
101
|
+
type TerminalQuestionPromptProps,
|
|
102
|
+
} from './components/terminal/QuestionPrompt.tsx'
|
|
103
|
+
export { TerminalDiff, previewPatch } from './components/terminal/diff.tsx'
|
|
104
|
+
export { TerminalMarkdown, type TerminalMarkdownProps } from './components/terminal/markdown.tsx'
|
|
105
|
+
export { Band, Blank, Ink, Row, type RowProps, type Tone } from './components/terminal/row.tsx'
|
|
106
|
+
export {
|
|
107
|
+
CopyAction,
|
|
108
|
+
WithActions,
|
|
109
|
+
useAffordances,
|
|
110
|
+
type TerminalAffordances,
|
|
111
|
+
} from './components/terminal/affordances.tsx'
|
|
78
112
|
export {
|
|
79
113
|
Conversation,
|
|
80
114
|
ConversationContent,
|
|
@@ -88,6 +122,7 @@ export {
|
|
|
88
122
|
useTranscriptDensity,
|
|
89
123
|
useTranscriptVariant,
|
|
90
124
|
type TranscriptDensity,
|
|
125
|
+
type TranscriptFont,
|
|
91
126
|
type TranscriptVariant,
|
|
92
127
|
} from './components/agent/transcript-variant.tsx'
|
|
93
128
|
export { Response, type ResponseProps } from './components/agent/Response.tsx'
|
|
@@ -123,6 +158,7 @@ export {
|
|
|
123
158
|
export { StatusBar, type StatusBarProps } from './components/agent/StatusBar.tsx'
|
|
124
159
|
export { ContextDialog, type ContextDialogProps } from './components/agent/ContextDialog.tsx'
|
|
125
160
|
export { UsageDialog, type UsageDialogProps } from './components/agent/UsageDialog.tsx'
|
|
161
|
+
export { UsageMeters, useMinuteClock } from './components/agent/UsageMeters.tsx'
|
|
126
162
|
export {
|
|
127
163
|
SessionInfoDialog,
|
|
128
164
|
type SessionInfoDialogProps,
|
package/src/lib/status.ts
CHANGED
|
@@ -63,8 +63,59 @@ export function meterSeverity(pct: number | undefined): StatusSeverity {
|
|
|
63
63
|
return 'none'
|
|
64
64
|
}
|
|
65
65
|
|
|
66
|
+
/**
|
|
67
|
+
* The three *lanes* a plan-usage reading can occupy, and the whole reason this
|
|
68
|
+
* is a rule rather than a per-client `if`.
|
|
69
|
+
*
|
|
70
|
+
* The CLI reports one window per limit (`five_hour`, `seven_day`, and a
|
|
71
|
+
* `seven_day_<model>` bucket per model-scoped limit — `seven_day_opus`,
|
|
72
|
+
* `seven_day_sonnet`, and whatever `model_scoped` names next). A surface with
|
|
73
|
+
* one slot shows the fullest of them, which is why `tightestWindow` exists —
|
|
74
|
+
* but that answers "what is closest to blocking me", not "how much of *this
|
|
75
|
+
* session's* budget have I spent", and those are different questions a reader
|
|
76
|
+
* asks at different times. A weekly window at 71% will win the single slot over
|
|
77
|
+
* a five-hour window at 60% every time, so the reading you actually watch while
|
|
78
|
+
* working is the one you can never see.
|
|
79
|
+
*
|
|
80
|
+
* Hence three lanes, each independently showable:
|
|
81
|
+
*
|
|
82
|
+
* - `'session'` — the five-hour window. The one that resets while you work.
|
|
83
|
+
* - `'weekly'` — the plain seven-day window, the account-wide ceiling.
|
|
84
|
+
* - `'model'` — the fullest of the *model-scoped* weekly buckets. Deliberately
|
|
85
|
+
* not a named model: which models have their own bucket is the plan's
|
|
86
|
+
* business and changes without notice, so a client that hardcoded
|
|
87
|
+
* `seven_day_opus` would show nothing the month it becomes something else.
|
|
88
|
+
* The label comes from the key, so this lane names whatever it found.
|
|
89
|
+
*/
|
|
90
|
+
export type UsageLane = 'session' | 'weekly' | 'model'
|
|
91
|
+
|
|
92
|
+
/** The window a lane points at, or `undefined` when this account has none. */
|
|
93
|
+
export function usageWindow(
|
|
94
|
+
rateLimits: Record<string, RateLimitInfo> | undefined,
|
|
95
|
+
lane: UsageLane,
|
|
96
|
+
): { key: string; info: RateLimitInfo } | undefined {
|
|
97
|
+
if (!rateLimits) return undefined
|
|
98
|
+
if (lane === 'session') {
|
|
99
|
+
const info = rateLimits.five_hour
|
|
100
|
+
return info ? { key: 'five_hour', info } : undefined
|
|
101
|
+
}
|
|
102
|
+
if (lane === 'weekly') {
|
|
103
|
+
const info = rateLimits.seven_day
|
|
104
|
+
return info ? { key: 'seven_day', info } : undefined
|
|
105
|
+
}
|
|
106
|
+
// Model-scoped: same "fullest wins" rule as the single slot, over the subset.
|
|
107
|
+
const scoped = Object.fromEntries(
|
|
108
|
+
Object.entries(rateLimits).filter(
|
|
109
|
+
([key]) => key.startsWith('seven_day_') && key !== 'seven_day_oauth_apps',
|
|
110
|
+
),
|
|
111
|
+
)
|
|
112
|
+
return tightestWindow(scoped)
|
|
113
|
+
}
|
|
114
|
+
|
|
66
115
|
/** The rate-limit window that gets the one visible slot: whichever is fullest,
|
|
67
|
-
* since the binding constraint is the one worth glancing at.
|
|
116
|
+
* since the binding constraint is the one worth glancing at. Still the right
|
|
117
|
+
* rule for a surface with exactly one slot; {@link usageWindow} is for one with
|
|
118
|
+
* three. */
|
|
68
119
|
export function tightestWindow(
|
|
69
120
|
rateLimits: Record<string, RateLimitInfo> | undefined,
|
|
70
121
|
): { key: string; info: RateLimitInfo } | undefined {
|
|
@@ -85,11 +136,16 @@ export function tightestWindow(
|
|
|
85
136
|
return best
|
|
86
137
|
}
|
|
87
138
|
|
|
88
|
-
/** A rate-limit window's key, named for a human.
|
|
139
|
+
/** A rate-limit window's key, named for a human. A model-scoped bucket is named
|
|
140
|
+
* for its model alone (`seven_day_fable` → "Fable"): the lane it sits in
|
|
141
|
+
* already says weekly, and "Seven day fable" in a status bar is three words to
|
|
142
|
+
* say one. */
|
|
89
143
|
export function windowLabel(key: string): string {
|
|
90
144
|
if (key === 'five_hour') return 'Session'
|
|
91
145
|
if (key === 'seven_day') return 'Weekly'
|
|
92
|
-
|
|
146
|
+
const scoped = key.startsWith('seven_day_') ? key.slice('seven_day_'.length) : key
|
|
147
|
+
const words = scoped.replaceAll('_', ' ')
|
|
148
|
+
return words.charAt(0).toUpperCase() + words.slice(1)
|
|
93
149
|
}
|
|
94
150
|
|
|
95
151
|
export type ModelReadings = { model?: string; models: readonly ModelOption[] }
|
package/src/lib/tool-icon.ts
CHANGED
|
@@ -73,6 +73,20 @@ export function toolIcon(toolName: string): LucideIcon {
|
|
|
73
73
|
}
|
|
74
74
|
}
|
|
75
75
|
|
|
76
|
+
/**
|
|
77
|
+
* Is this tool a shell command?
|
|
78
|
+
*
|
|
79
|
+
* Its own question because a run of them is *one* thing the reader skims past:
|
|
80
|
+
* the terminal theme collapses consecutive shell calls into a single "Ran N
|
|
81
|
+
* shell commands" line, the way the CLI does. Names from both first-party
|
|
82
|
+
* engines. `BashOutput`/`KillShell` are excluded on purpose — they manage a
|
|
83
|
+
* background shell rather than run something, and folding them into the count
|
|
84
|
+
* would inflate it.
|
|
85
|
+
*/
|
|
86
|
+
export function isShellTool(toolName: string): boolean {
|
|
87
|
+
return toolName === 'Bash' || toolName === 'CodexCommand'
|
|
88
|
+
}
|
|
89
|
+
|
|
76
90
|
/**
|
|
77
91
|
* Does this tool *change* the workspace?
|
|
78
92
|
*
|