@workerdeck/ui 2.13.0 → 2.14.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/build/{SessionPanel-CJwp5B3q.d.mts → SessionPanel-BVKqPs_3.d.mts} +9 -3
- package/build/index.d.mts +84 -6
- package/build/index.mjs +83 -37
- package/build/index.mjs.map +1 -1
- package/build/scoped.css +24 -3
- package/build/{use-path-links-CepFsMdW.mjs → use-path-links-BgWXmfAS.mjs} +849 -236
- package/build/use-path-links-BgWXmfAS.mjs.map +1 -0
- package/build/workspace.d.mts +19 -4
- package/build/workspace.mjs +7 -4
- package/build/workspace.mjs.map +1 -1
- package/package.json +16 -6
- package/src/components/agent/Composer.tsx +18 -13
- package/src/components/agent/SessionBrowser.tsx +24 -1
- package/src/components/agent/SessionItem.tsx +13 -3
- package/src/components/agent/SessionPanel.tsx +264 -211
- package/src/components/agent/SessionSteps.tsx +78 -27
- package/src/components/agent/SessionWorkspace.tsx +10 -1
- package/src/components/agent/ShellStrip.tsx +71 -0
- package/src/components/agent/ShellTerminal.tsx +170 -0
- package/src/components/agent/SubagentToggle.tsx +7 -1
- package/src/components/agent/TranscriptItemView.tsx +74 -1
- package/src/components/agent/scrubber-marks.ts +9 -1
- package/src/components/agent/shell-actions.tsx +25 -0
- package/src/components/agent/use-shell-frame.ts +111 -0
- package/src/components/terminal/TerminalTranscript.tsx +4 -0
- package/src/components/terminal/affordances.tsx +37 -0
- package/src/components/terminal/height.ts +14 -0
- package/src/components/terminal/items.tsx +72 -3
- package/src/components/terminal/press.tsx +5 -3
- package/src/components/terminal/scrubber.tsx +9 -1
- package/src/components/terminal/shell-row.ts +106 -0
- package/src/index.ts +12 -1
- package/src/styles/terminal.css +11 -0
- package/src/styles/theme.css +3 -0
- package/src/workspace.ts +1 -0
- package/build/use-path-links-CepFsMdW.mjs.map +0 -1
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
|
2
|
+
import type { SessionInfo, ShellInfo } from '@workerdeck/protocol'
|
|
3
|
+
import type { ShellItem, TranscriptItem } from '@workerdeck/react'
|
|
4
|
+
|
|
5
|
+
export type ShellFrameOptions = {
|
|
6
|
+
sessionId: string | undefined
|
|
7
|
+
items: readonly TranscriptItem[]
|
|
8
|
+
session: SessionInfo | undefined
|
|
9
|
+
shells?: readonly ShellInfo[]
|
|
10
|
+
reveal?: { toolUseId: string; nonce: number }
|
|
11
|
+
openShell?: { shellId: string; nonce: number }
|
|
12
|
+
onShellChange?: (shellId: string | undefined) => void
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export type ShellFrame = {
|
|
16
|
+
shellId: string | undefined
|
|
17
|
+
enterShell: (shellId: string) => void
|
|
18
|
+
leaveShell: () => void
|
|
19
|
+
returnReveal: { toolUseId: string; nonce: number } | undefined
|
|
20
|
+
shell: ShellInfo | undefined
|
|
21
|
+
label: string
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
// The shell frame machine, the sub-agent frame's twin: entry keys on the nonce alone, Escape
|
|
25
|
+
// leaves, and the report is deduped through a ref so an echo of our own report is inert.
|
|
26
|
+
export function useShellFrame(options: ShellFrameOptions): ShellFrame {
|
|
27
|
+
const { sessionId, items, session, shells, reveal, openShell, onShellChange } = options
|
|
28
|
+
|
|
29
|
+
const [shellId, setShellId] = useState<string | undefined>(undefined)
|
|
30
|
+
const [returnReveal, setReturnReveal] = useState<{ toolUseId: string; nonce: number } | undefined>(undefined)
|
|
31
|
+
useEffect(() => {
|
|
32
|
+
setShellId(undefined)
|
|
33
|
+
setReturnReveal(undefined)
|
|
34
|
+
}, [sessionId])
|
|
35
|
+
|
|
36
|
+
const rowIdRef = useRef<string | undefined>(undefined)
|
|
37
|
+
const row = useMemo(
|
|
38
|
+
() => (shellId === undefined ? undefined : items.find((item): item is ShellItem => item.kind === 'shell' && item.shell.id === shellId)),
|
|
39
|
+
[items, shellId],
|
|
40
|
+
)
|
|
41
|
+
rowIdRef.current = row?.id
|
|
42
|
+
|
|
43
|
+
// Leaving reveals the `$` row the frame came from, so the reader lands where they left.
|
|
44
|
+
const leaveShell = useCallback(() => {
|
|
45
|
+
setShellId((current) => {
|
|
46
|
+
const rowId = rowIdRef.current
|
|
47
|
+
if (current !== undefined && rowId !== undefined) {
|
|
48
|
+
setReturnReveal({ toolUseId: rowId, nonce: Date.now() })
|
|
49
|
+
}
|
|
50
|
+
return undefined
|
|
51
|
+
})
|
|
52
|
+
}, [])
|
|
53
|
+
|
|
54
|
+
const openShellNonce = openShell?.nonce
|
|
55
|
+
const openShellId = openShell?.shellId
|
|
56
|
+
useEffect(() => {
|
|
57
|
+
if (openShellId === undefined) {
|
|
58
|
+
leaveShell()
|
|
59
|
+
} else {
|
|
60
|
+
setShellId(openShellId)
|
|
61
|
+
}
|
|
62
|
+
// Keyed on the nonce alone: an unchanged nonce is the host echoing our own report, and
|
|
63
|
+
// acting on it starts the URL -> panel -> URL cycle.
|
|
64
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
65
|
+
}, [openShellNonce])
|
|
66
|
+
|
|
67
|
+
const revealNonce = reveal?.nonce
|
|
68
|
+
useEffect(() => {
|
|
69
|
+
if (revealNonce === undefined) {
|
|
70
|
+
return
|
|
71
|
+
}
|
|
72
|
+
setShellId(undefined)
|
|
73
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
74
|
+
}, [revealNonce])
|
|
75
|
+
|
|
76
|
+
useEffect(() => {
|
|
77
|
+
if (shellId === undefined) {
|
|
78
|
+
return
|
|
79
|
+
}
|
|
80
|
+
const onKey = (event: KeyboardEvent) => {
|
|
81
|
+
if (event.key !== 'Escape' || event.defaultPrevented) {
|
|
82
|
+
return
|
|
83
|
+
}
|
|
84
|
+
leaveShell()
|
|
85
|
+
}
|
|
86
|
+
window.addEventListener('keydown', onKey)
|
|
87
|
+
return () => window.removeEventListener('keydown', onKey)
|
|
88
|
+
}, [shellId, leaveShell])
|
|
89
|
+
|
|
90
|
+
const onShellChangeRef = useRef(onShellChange)
|
|
91
|
+
onShellChangeRef.current = onShellChange
|
|
92
|
+
const reportedShellId = useRef<string | undefined>(undefined)
|
|
93
|
+
useEffect(() => {
|
|
94
|
+
if (reportedShellId.current === shellId) {
|
|
95
|
+
return
|
|
96
|
+
}
|
|
97
|
+
reportedShellId.current = shellId
|
|
98
|
+
onShellChangeRef.current?.(shellId)
|
|
99
|
+
}, [shellId])
|
|
100
|
+
|
|
101
|
+
const shell = useMemo(() => {
|
|
102
|
+
if (shellId === undefined) {
|
|
103
|
+
return undefined
|
|
104
|
+
}
|
|
105
|
+
return row?.shell ?? shells?.find((record) => record.id === shellId) ?? session?.shells?.find((record) => record.id === shellId)
|
|
106
|
+
}, [row, shells, session, shellId])
|
|
107
|
+
|
|
108
|
+
const label = shell ? shell.label || shell.command.split('\n')[0] || 'shell' : 'shell'
|
|
109
|
+
|
|
110
|
+
return { shellId, enterShell: setShellId, leaveShell, returnReveal, shell, label }
|
|
111
|
+
}
|
|
@@ -10,6 +10,7 @@ import {
|
|
|
10
10
|
NoticeRow,
|
|
11
11
|
PeerSendRow,
|
|
12
12
|
RunRow,
|
|
13
|
+
ShellRow,
|
|
13
14
|
ThinkingRow,
|
|
14
15
|
ToolRow,
|
|
15
16
|
TurnResultRow,
|
|
@@ -60,6 +61,9 @@ export function TerminalItemView({ item, fileUrl }: { item: TranscriptItem; file
|
|
|
60
61
|
case 'file_delivered': {
|
|
61
62
|
return <FileRow item={item} href={fileUrl?.(item.path)} />
|
|
62
63
|
}
|
|
64
|
+
case 'shell': {
|
|
65
|
+
return <ShellRow item={item} />
|
|
66
|
+
}
|
|
63
67
|
default: {
|
|
64
68
|
return null
|
|
65
69
|
}
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { createContext, useContext, useState, type ReactNode } from 'react'
|
|
2
2
|
import { copyText } from '../../lib/clipboard.ts'
|
|
3
|
+
import { SHELL_KILL_GLYPH } from './shell-row.ts'
|
|
3
4
|
import { cn } from '../../lib/utils.ts'
|
|
4
5
|
|
|
5
6
|
export type TerminalAffordances = {
|
|
@@ -97,6 +98,42 @@ export function OpenSubagentAction({ onOpen, label = 'Open sub-agent' }: { onOpe
|
|
|
97
98
|
)
|
|
98
99
|
}
|
|
99
100
|
|
|
101
|
+
export function OpenShellAction({ onOpen, label = 'Open terminal' }: { onOpen: () => void; label?: string }) {
|
|
102
|
+
return (
|
|
103
|
+
<button
|
|
104
|
+
type="button"
|
|
105
|
+
className="term-action"
|
|
106
|
+
title={label}
|
|
107
|
+
aria-label={label}
|
|
108
|
+
data-tone="magenta"
|
|
109
|
+
onClick={(event) => {
|
|
110
|
+
event.stopPropagation()
|
|
111
|
+
onOpen()
|
|
112
|
+
}}
|
|
113
|
+
>
|
|
114
|
+
⤢
|
|
115
|
+
</button>
|
|
116
|
+
)
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
export function KillShellAction({ onKill, label = 'Kill this shell' }: { onKill: () => void; label?: string }) {
|
|
120
|
+
return (
|
|
121
|
+
<button
|
|
122
|
+
type="button"
|
|
123
|
+
className="term-action"
|
|
124
|
+
title={label}
|
|
125
|
+
aria-label={label}
|
|
126
|
+
data-tone="red"
|
|
127
|
+
onClick={(event) => {
|
|
128
|
+
event.stopPropagation()
|
|
129
|
+
onKill()
|
|
130
|
+
}}
|
|
131
|
+
>
|
|
132
|
+
{SHELL_KILL_GLYPH}
|
|
133
|
+
</button>
|
|
134
|
+
)
|
|
135
|
+
}
|
|
136
|
+
|
|
100
137
|
export function CopyAction({ text, label = 'Copy' }: { text: string; label?: string }) {
|
|
101
138
|
const [copied, setCopied] = useState(false)
|
|
102
139
|
return (
|
|
@@ -4,6 +4,7 @@ import { compactionText, formatBytes, formatCost, formatDuration, toolInputPrevi
|
|
|
4
4
|
import { taskChildItems, type TerminalBlock, type ToolCallItem } from './blocks.ts'
|
|
5
5
|
import { IMAGE_BOX_LINES } from './image-box.ts'
|
|
6
6
|
import { collapsedResult } from './result-preview.ts'
|
|
7
|
+
import { shellBodyLines, shellFooterText, shellHeaderText } from './shell-row.ts'
|
|
7
8
|
import { todoLine, todoPreview } from './todos.ts'
|
|
8
9
|
import { isPeerSend, peerName, planRun, runSummary, taskSummary } from './tool-run.ts'
|
|
9
10
|
|
|
@@ -634,6 +635,19 @@ export function itemHeight(item: TranscriptItem, m: CellMetrics): ComputedHeight
|
|
|
634
635
|
const text = `${item.path} · ${formatBytes(item.bytes)}` + (item.description ? ` · ${item.description}` : '')
|
|
635
636
|
return rowH(text, m, { extraPx })
|
|
636
637
|
}
|
|
638
|
+
case 'shell': {
|
|
639
|
+
// Collapsed, as the row first draws: expanding is a state change the epoch's cache is invalidated by anyway.
|
|
640
|
+
const lines = shellBodyLines(item, false)
|
|
641
|
+
let acc = rowH(shellHeaderText(item), m, { extraPx })
|
|
642
|
+
for (const line of lines) {
|
|
643
|
+
acc = add(acc, rowH(line || ' ', m, { extraPx }))
|
|
644
|
+
}
|
|
645
|
+
const footer = shellFooterText(item, false, lines.length)
|
|
646
|
+
if (footer) {
|
|
647
|
+
acc = add(acc, rowH(footer, m, { extraPx }))
|
|
648
|
+
}
|
|
649
|
+
return acc
|
|
650
|
+
}
|
|
637
651
|
default: {
|
|
638
652
|
return { px: 0, exact: false }
|
|
639
653
|
}
|
|
@@ -1,15 +1,15 @@
|
|
|
1
1
|
import { useEffect, useState, type ReactNode } from 'react'
|
|
2
2
|
import type { MessageOrigin } from '@workerdeck/protocol'
|
|
3
|
-
import type { TranscriptItem } from '@workerdeck/react'
|
|
3
|
+
import type { ShellItem, TranscriptItem } from '@workerdeck/react'
|
|
4
4
|
import { compactionText, formatBytes, formatCost, formatDuration, toolInputPreview } from '../../lib/format.ts'
|
|
5
5
|
import { isMutatingTool } from '../../lib/tool-icon.ts'
|
|
6
6
|
import { usePulse } from '../agent/pulse.tsx'
|
|
7
7
|
import { PromptTokenText } from '../agent/PromptTokenText.tsx'
|
|
8
|
-
import { BookmarkAction, CopyAction, WithActions } from './affordances.tsx'
|
|
8
|
+
import { BookmarkAction, CopyAction, KillShellAction, OpenShellAction, WithActions } from './affordances.tsx'
|
|
9
9
|
import { TerminalDiff } from './diff.tsx'
|
|
10
10
|
import { TerminalMarkdown } from './markdown.tsx'
|
|
11
11
|
import { usePeerNames } from './peer-names.tsx'
|
|
12
|
-
import { Pressable, useRevealOnOpen } from './press.tsx'
|
|
12
|
+
import { Pressable, useRevealOnOpen, type PressModifiers } from './press.tsx'
|
|
13
13
|
import { IMAGE_BOX_LINES, IMAGE_UNAVAILABLE, imagePlaceholder } from './image-box.ts'
|
|
14
14
|
import { collapsedResult } from './result-preview.ts'
|
|
15
15
|
import { useToolResultFetcher } from '../agent/tool-result-fetch.tsx'
|
|
@@ -17,6 +17,8 @@ import { useToolTitle } from '../agent/tool-titles.tsx'
|
|
|
17
17
|
import { useToolResultImageSrc } from '../agent/tool-result-image.tsx'
|
|
18
18
|
import { isPeerSend, peerName, peerOneLine, peerSendTarget, peerSendText, planRun, runFailed, runSummary } from './tool-run.ts'
|
|
19
19
|
import { todoLine, todoPreview, type TodoPreview, type TodoStatus } from './todos.ts'
|
|
20
|
+
import { useShellActions } from '../agent/shell-actions.tsx'
|
|
21
|
+
import { SHELL_GLYPH, shellBodyLines, shellFailed, shellFooterText, shellLabel, shellStatusText } from './shell-row.ts'
|
|
20
22
|
import { type ToolCallItem } from './blocks.ts'
|
|
21
23
|
import { Band, Blank, Ink, Row, type Tone } from './row.tsx'
|
|
22
24
|
|
|
@@ -386,6 +388,73 @@ export function NoticeRow({ item }: { item: Extract<TranscriptItem, { kind: 'not
|
|
|
386
388
|
)
|
|
387
389
|
}
|
|
388
390
|
|
|
391
|
+
export function ShellRow({ item }: { item: ShellItem }) {
|
|
392
|
+
const [open, setOpen] = useState(false)
|
|
393
|
+
const [busy, setBusy] = useState(false)
|
|
394
|
+
const actions = useShellActions()
|
|
395
|
+
const reveal = useRevealOnOpen(open)
|
|
396
|
+
const running = item.shell.status === 'running'
|
|
397
|
+
const failed = shellFailed(item)
|
|
398
|
+
const tone: Tone = failed ? 'red' : running ? 'magenta' : 'dim'
|
|
399
|
+
const lines = shellBodyLines(item, open)
|
|
400
|
+
const footer = shellFooterText(item, open, lines.length)
|
|
401
|
+
|
|
402
|
+
const shellId = item.shell.id
|
|
403
|
+
const verify = actions.verify
|
|
404
|
+
useEffect(() => {
|
|
405
|
+
if (running) {
|
|
406
|
+
void verify(shellId)
|
|
407
|
+
}
|
|
408
|
+
}, [running, shellId, verify])
|
|
409
|
+
|
|
410
|
+
const press = (modifiers: PressModifiers) => {
|
|
411
|
+
if ((modifiers.meta || modifiers.ctrl) && actions.open) {
|
|
412
|
+
actions.open(shellId)
|
|
413
|
+
return
|
|
414
|
+
}
|
|
415
|
+
const next = !open
|
|
416
|
+
setOpen(next)
|
|
417
|
+
if (!next || !item.truncated || item.expanded !== undefined || item.missing) {
|
|
418
|
+
return
|
|
419
|
+
}
|
|
420
|
+
setBusy(true)
|
|
421
|
+
void actions.loadOutput(shellId).finally(() => setBusy(false))
|
|
422
|
+
}
|
|
423
|
+
|
|
424
|
+
return (
|
|
425
|
+
<div ref={reveal} className={open ? 'term-open' : undefined}>
|
|
426
|
+
<WithActions
|
|
427
|
+
actions={
|
|
428
|
+
<>
|
|
429
|
+
{actions.open ? <OpenShellAction onOpen={() => actions.open?.(shellId)} /> : null}
|
|
430
|
+
{running ? <KillShellAction onKill={() => void actions.kill(shellId)} /> : null}
|
|
431
|
+
<BookmarkAction id={item.id} />
|
|
432
|
+
<CopyAction text={item.shell.command} label="Copy command" />
|
|
433
|
+
</>
|
|
434
|
+
}
|
|
435
|
+
>
|
|
436
|
+
<Pressable onPress={press} expanded={open}>
|
|
437
|
+
<Row glyph={SHELL_GLYPH} glyphTone={tone} tone="fg">
|
|
438
|
+
<Ink bold tone="bright">
|
|
439
|
+
{shellLabel(item)}
|
|
440
|
+
</Ink>
|
|
441
|
+
<Ink tone={failed ? 'red' : 'faint'}>
|
|
442
|
+
{' · '}
|
|
443
|
+
{shellStatusText(item)}
|
|
444
|
+
</Ink>
|
|
445
|
+
</Row>
|
|
446
|
+
</Pressable>
|
|
447
|
+
{lines.map((line, index) => (
|
|
448
|
+
<Row key={index} tone={failed ? 'red' : 'dim'}>
|
|
449
|
+
{line || ' '}
|
|
450
|
+
</Row>
|
|
451
|
+
))}
|
|
452
|
+
{footer || busy ? <Row tone="faint">{busy ? '… fetching the full output' : footer}</Row> : null}
|
|
453
|
+
</WithActions>
|
|
454
|
+
</div>
|
|
455
|
+
)
|
|
456
|
+
}
|
|
457
|
+
|
|
389
458
|
export function FileRow({ item, href }: { item: Extract<TranscriptItem, { kind: 'file_delivered' }>; href?: string }) {
|
|
390
459
|
return (
|
|
391
460
|
<Row glyph="⤓" glyphTone="blue" tone="dim">
|
|
@@ -3,13 +3,15 @@ import { cn } from '../../lib/utils.ts'
|
|
|
3
3
|
|
|
4
4
|
const DRAG_SLOP = 4
|
|
5
5
|
|
|
6
|
+
export type PressModifiers = { meta: boolean; ctrl: boolean; alt: boolean }
|
|
7
|
+
|
|
6
8
|
export function Pressable({
|
|
7
9
|
onPress,
|
|
8
10
|
expanded,
|
|
9
11
|
className,
|
|
10
12
|
children,
|
|
11
13
|
}: {
|
|
12
|
-
onPress: () => void
|
|
14
|
+
onPress: (modifiers: PressModifiers) => void
|
|
13
15
|
expanded?: boolean
|
|
14
16
|
className?: string
|
|
15
17
|
children: ReactNode
|
|
@@ -34,14 +36,14 @@ export function Pressable({
|
|
|
34
36
|
if (selection && !selection.isCollapsed && selection.containsNode(event.currentTarget, true)) {
|
|
35
37
|
return
|
|
36
38
|
}
|
|
37
|
-
onPress()
|
|
39
|
+
onPress({ meta: event.metaKey, ctrl: event.ctrlKey, alt: event.altKey })
|
|
38
40
|
}}
|
|
39
41
|
onKeyDown={(event) => {
|
|
40
42
|
if (event.key !== 'Enter' && event.key !== ' ') {
|
|
41
43
|
return
|
|
42
44
|
}
|
|
43
45
|
event.preventDefault()
|
|
44
|
-
onPress()
|
|
46
|
+
onPress({ meta: event.metaKey, ctrl: event.ctrlKey, alt: event.altKey })
|
|
45
47
|
}}
|
|
46
48
|
>
|
|
47
49
|
{children}
|
|
@@ -7,7 +7,7 @@ import { parentOf } from './blocks.ts'
|
|
|
7
7
|
import { TerminalSurface } from './surface.tsx'
|
|
8
8
|
|
|
9
9
|
type Lane = 'l' | 'r' | 'f'
|
|
10
|
-
type MarkKind = 'user' | 'subagent' | 'turn' | 'turnFailed' | 'toolFailed' | 'error' | 'approval' | 'recap' | 'bookmark'
|
|
10
|
+
type MarkKind = 'user' | 'subagent' | 'shell' | 'turn' | 'turnFailed' | 'toolFailed' | 'error' | 'approval' | 'recap' | 'bookmark'
|
|
11
11
|
|
|
12
12
|
type Mark = {
|
|
13
13
|
kind: MarkKind
|
|
@@ -31,6 +31,7 @@ function nearestMember(cluster: Cluster, y: number): Mark | undefined {
|
|
|
31
31
|
const LANE: Record<MarkKind, Lane> = {
|
|
32
32
|
user: 'l',
|
|
33
33
|
subagent: 'l',
|
|
34
|
+
shell: 'l',
|
|
34
35
|
turn: 'r',
|
|
35
36
|
turnFailed: 'r',
|
|
36
37
|
toolFailed: 'r',
|
|
@@ -49,12 +50,14 @@ const LOUDNESS: Record<MarkKind, number> = {
|
|
|
49
50
|
turn: 2,
|
|
50
51
|
bookmark: 1,
|
|
51
52
|
subagent: 1,
|
|
53
|
+
shell: 1,
|
|
52
54
|
recap: 0,
|
|
53
55
|
}
|
|
54
56
|
|
|
55
57
|
const KIND_NAME: Record<MarkKind, string> = {
|
|
56
58
|
user: 'you',
|
|
57
59
|
subagent: 'sub-agent',
|
|
60
|
+
shell: 'shell',
|
|
58
61
|
turn: 'response · turn end',
|
|
59
62
|
turnFailed: 'turn failed',
|
|
60
63
|
toolFailed: 'tool failed',
|
|
@@ -84,6 +87,9 @@ function excerpt(item: TranscriptItem): string {
|
|
|
84
87
|
case 'notice': {
|
|
85
88
|
return item.text
|
|
86
89
|
}
|
|
90
|
+
case 'shell': {
|
|
91
|
+
return item.shell.command
|
|
92
|
+
}
|
|
87
93
|
case 'tool_call': {
|
|
88
94
|
return `${item.name}(${toolInputPreview(item.input)})`
|
|
89
95
|
}
|
|
@@ -174,6 +180,8 @@ export function buildClusters(props: TerminalScrubberProps, railH: number): Clus
|
|
|
174
180
|
closeSegment()
|
|
175
181
|
} else if (item.kind === 'notice' && item.level === 'error') {
|
|
176
182
|
marks.push({ kind: 'error', itemIndex: index, rowIndex: rowIndexFor(index) })
|
|
183
|
+
} else if (item.kind === 'shell') {
|
|
184
|
+
marks.push({ kind: 'shell', itemIndex: index, rowIndex: rowIndexFor(index) })
|
|
177
185
|
} else if (
|
|
178
186
|
item.kind === 'tool_call' &&
|
|
179
187
|
(item.status === 'failed' || item.result?.isError === true) &&
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
import type { ShellInfo } from '@workerdeck/protocol'
|
|
2
|
+
import type { ShellItem } from '@workerdeck/react'
|
|
3
|
+
|
|
4
|
+
export const SHELL_GLYPH = '$'
|
|
5
|
+
|
|
6
|
+
export const SHELL_KILL_GLYPH = '✕'
|
|
7
|
+
|
|
8
|
+
export const SHELL_EXPAND_CHARS = 100_000
|
|
9
|
+
|
|
10
|
+
export const SHELL_MISSING = 'output expired or not tracked by this gateway'
|
|
11
|
+
|
|
12
|
+
export function shellInfoLabel(shell: ShellInfo): string {
|
|
13
|
+
return shell.label || shell.command.split('\n')[0] || ''
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export function shellTitle(shell: ShellInfo): string {
|
|
17
|
+
return `#${shell.ordinal} ${shellInfoLabel(shell)}`
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export function shellLabel(item: ShellItem): string {
|
|
21
|
+
return shellInfoLabel(item.shell)
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export function shellStatusText(item: ShellItem): string {
|
|
25
|
+
return shellInfoStatusText(item.shell)
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export function shellInfoFailed(shell: ShellInfo): boolean {
|
|
29
|
+
return shell.status === 'exited' && shell.exitCode !== 0
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export function shellInfoStatusText(shell: ShellInfo): string {
|
|
33
|
+
if (shell.status === 'running') {
|
|
34
|
+
return 'running'
|
|
35
|
+
}
|
|
36
|
+
if (typeof shell.exitCode === 'number') {
|
|
37
|
+
return shell.exitCode === 0 ? 'exit 0' : `exit ${shell.exitCode}`
|
|
38
|
+
}
|
|
39
|
+
switch (shell.endReason) {
|
|
40
|
+
case 'killed': {
|
|
41
|
+
return 'killed'
|
|
42
|
+
}
|
|
43
|
+
case 'timeout': {
|
|
44
|
+
return 'timed out'
|
|
45
|
+
}
|
|
46
|
+
case 'server_stopped': {
|
|
47
|
+
return 'killed: the gateway stopped'
|
|
48
|
+
}
|
|
49
|
+
case 'server_restarted': {
|
|
50
|
+
return 'ended: the gateway restarted, the process may still be running'
|
|
51
|
+
}
|
|
52
|
+
case 'spawn_failed': {
|
|
53
|
+
return 'failed to start'
|
|
54
|
+
}
|
|
55
|
+
default: {
|
|
56
|
+
return 'ended'
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
export function shellFailed(item: ShellItem): boolean {
|
|
62
|
+
return shellInfoFailed(item.shell)
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
export function shellHeaderText(item: ShellItem): string {
|
|
66
|
+
return `${shellLabel(item)} · ${shellStatusText(item)}`
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
export function shellBodyLines(item: ShellItem, open: boolean): string[] {
|
|
70
|
+
const source = open && item.expanded !== undefined ? item.expanded : item.text
|
|
71
|
+
if (source === '') {
|
|
72
|
+
return []
|
|
73
|
+
}
|
|
74
|
+
const lines = source.split('\n')
|
|
75
|
+
if (!open) {
|
|
76
|
+
return lines
|
|
77
|
+
}
|
|
78
|
+
const kept: string[] = []
|
|
79
|
+
let chars = 0
|
|
80
|
+
for (const line of lines) {
|
|
81
|
+
if (kept.length > 0 && chars + line.length > SHELL_EXPAND_CHARS) {
|
|
82
|
+
break
|
|
83
|
+
}
|
|
84
|
+
kept.push(line)
|
|
85
|
+
chars += line.length + 1
|
|
86
|
+
}
|
|
87
|
+
return kept
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
// The footer is the row's one affordance line: what expanding will fetch, what it clipped, or why it cannot.
|
|
91
|
+
export function shellFooterText(item: ShellItem, open: boolean, shown: number): string | undefined {
|
|
92
|
+
if (item.missing) {
|
|
93
|
+
return SHELL_MISSING
|
|
94
|
+
}
|
|
95
|
+
if (!open) {
|
|
96
|
+
return item.truncated ? '… more output - expand to fetch it' : undefined
|
|
97
|
+
}
|
|
98
|
+
const total = item.expanded === undefined ? undefined : item.expanded.split('\n').length
|
|
99
|
+
if (total !== undefined && total > shown) {
|
|
100
|
+
return `… +${(total - shown).toLocaleString()} lines not shown`
|
|
101
|
+
}
|
|
102
|
+
if (item.expanded === undefined && item.truncated) {
|
|
103
|
+
return '… fetching the full output'
|
|
104
|
+
}
|
|
105
|
+
return undefined
|
|
106
|
+
}
|
package/src/index.ts
CHANGED
|
@@ -125,7 +125,7 @@ export { SessionBrowser, rowShapeClass, type SessionBrowserProps } from './compo
|
|
|
125
125
|
export { SessionItem, type SelectModifiers, type SessionItemProps } from './components/agent/SessionItem.tsx'
|
|
126
126
|
export { SessionStatusIcon } from './components/agent/SessionStatusIcon.tsx'
|
|
127
127
|
export { EngineIcon, engineMark, vendorMarkClass, vendorTextClass } from './components/agent/EngineIcon.tsx'
|
|
128
|
-
export { type Step, StepRow, sessionSteps } from './components/agent/SessionSteps.tsx'
|
|
128
|
+
export { type ShellStepOptions, type Step, type StepKind, StepRow, sessionSteps } from './components/agent/SessionSteps.tsx'
|
|
129
129
|
export {
|
|
130
130
|
SUBAGENT_DISPLAY_META,
|
|
131
131
|
SUBAGENT_DISPLAY_ORDER,
|
|
@@ -134,6 +134,17 @@ export {
|
|
|
134
134
|
type SubagentToggleProps,
|
|
135
135
|
} from './components/agent/SubagentToggle.tsx'
|
|
136
136
|
export { SubagentStrip } from './components/agent/SubagentStrip.tsx'
|
|
137
|
+
export { ShellStrip, type ShellStripProps } from './components/agent/ShellStrip.tsx'
|
|
138
|
+
export { useShellFrame, type ShellFrame, type ShellFrameOptions } from './components/agent/use-shell-frame.ts'
|
|
139
|
+
export { ShellActionsProvider, useShellActions, type ShellActions } from './components/agent/shell-actions.tsx'
|
|
140
|
+
export {
|
|
141
|
+
SHELL_GLYPH,
|
|
142
|
+
SHELL_KILL_GLYPH,
|
|
143
|
+
shellInfoFailed,
|
|
144
|
+
shellInfoLabel,
|
|
145
|
+
shellInfoStatusText,
|
|
146
|
+
shellTitle,
|
|
147
|
+
} from './components/terminal/shell-row.ts'
|
|
137
148
|
export { ProjectIcon } from './components/agent/ProjectIcon.tsx'
|
|
138
149
|
export { SessionEmptyState, type SessionEmptyStateProps } from './components/agent/SessionEmptyState.tsx'
|
|
139
150
|
export { PromptTokenText, SessionNamesProvider, SkillNamesProvider } from './components/agent/PromptTokenText.tsx'
|
package/src/styles/terminal.css
CHANGED
|
@@ -470,6 +470,10 @@
|
|
|
470
470
|
padding: 0;
|
|
471
471
|
cursor: pointer;
|
|
472
472
|
}
|
|
473
|
+
/* The shell drill-in, in the same magenta as the row's `$`. */
|
|
474
|
+
[data-terminal] .term-action[data-tone='magenta'] {
|
|
475
|
+
color: var(--term-magenta);
|
|
476
|
+
}
|
|
473
477
|
[data-terminal] .term-action:hover {
|
|
474
478
|
color: var(--term-fg);
|
|
475
479
|
}
|
|
@@ -1152,6 +1156,13 @@ button.term-link {
|
|
|
1152
1156
|
.term-scrubber .term-scrub-mark[data-kind='subagent'] {
|
|
1153
1157
|
background: linear-gradient(to bottom, var(--term-green) 0 2px, color-mix(in srgb, var(--term-green) 25%, transparent) 2px);
|
|
1154
1158
|
}
|
|
1159
|
+
/* A shell run, in the input lane because a `$` command is something you did:
|
|
1160
|
+
the composer's own magenta, at the bookmark's loudness so a long log never
|
|
1161
|
+
shouts over a failed turn. Drawn as an extent like the marks it shares the
|
|
1162
|
+
lane with. */
|
|
1163
|
+
.term-scrubber .term-scrub-mark[data-kind='shell'] {
|
|
1164
|
+
background: linear-gradient(to bottom, var(--wd-shell-accent) 0 2px, color-mix(in srgb, var(--wd-shell-accent) 25%, transparent) 2px);
|
|
1165
|
+
}
|
|
1155
1166
|
.term-scrubber .term-scrub-mark[data-kind='turn'] {
|
|
1156
1167
|
background: linear-gradient(to bottom, var(--term-fg) 0 2px, color-mix(in srgb, var(--term-fg) 25%, transparent) 2px);
|
|
1157
1168
|
}
|
package/src/styles/theme.css
CHANGED
|
@@ -827,6 +827,9 @@
|
|
|
827
827
|
.wd-scrubber .wd-scrub-mark[data-kind='subagent'] {
|
|
828
828
|
background: var(--success);
|
|
829
829
|
}
|
|
830
|
+
.wd-scrubber .wd-scrub-mark[data-kind='shell'] {
|
|
831
|
+
background: var(--wd-shell-accent);
|
|
832
|
+
}
|
|
830
833
|
.wd-scrubber .wd-scrub-mark[data-kind='turn'] {
|
|
831
834
|
background: var(--fg-3);
|
|
832
835
|
}
|
package/src/workspace.ts
CHANGED
|
@@ -3,3 +3,4 @@ export { FileTree, type FileTreeProps } from './components/agent/FileTree.tsx'
|
|
|
3
3
|
export { EditorTabs, type EditorTabsProps } from './components/agent/EditorTabs.tsx'
|
|
4
4
|
export { FileViewer, type FileViewerProps } from './components/agent/FileViewer.tsx'
|
|
5
5
|
export { CodeEditor, type CodeEditorProps } from './components/agent/CodeEditor.tsx'
|
|
6
|
+
export { ShellTerminal, type ShellTerminalProps } from './components/agent/ShellTerminal.tsx'
|