@workerdeck/ui 0.22.0 → 1.0.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-DgwjH4Ve.mjs → SessionPanel-DDgBkHsi.mjs} +2196 -3364
- package/build/SessionPanel-DDgBkHsi.mjs.map +1 -0
- package/build/SessionPanel-E_L_ldQ5.d.mts +207 -0
- package/build/format-B8ZM9LPy.d.mts +16 -0
- package/build/format.d.mts +8 -75
- package/build/format.mjs +2 -2
- package/build/index.d.mts +63 -1138
- package/build/index.mjs +4 -300
- package/build/index.mjs.map +1 -1
- package/build/scoped.css +80 -24
- package/build/{status-BE-zg88x.mjs → status-C0HSLZas.mjs} +14 -85
- package/build/status-C0HSLZas.mjs.map +1 -0
- package/build/workspace.d.mts +9 -124
- package/build/workspace.mjs +13 -143
- package/build/workspace.mjs.map +1 -1
- package/package.json +4 -4
- package/src/components/agent/CodeEditor.tsx +31 -103
- package/src/components/agent/Composer.tsx +114 -333
- package/src/components/agent/ContextDialog.tsx +20 -41
- package/src/components/agent/ContextRing.tsx +6 -28
- package/src/components/agent/Conversation.tsx +21 -33
- package/src/components/agent/EditorTabs.tsx +39 -67
- package/src/components/agent/EngineIcon.tsx +29 -72
- package/src/components/agent/EntryIcon.tsx +16 -0
- package/src/components/agent/FileCard.tsx +10 -16
- package/src/components/agent/FileTree.tsx +52 -125
- package/src/components/agent/FileViewer.tsx +30 -60
- package/src/components/agent/HostFilesDialog.tsx +40 -69
- package/src/components/agent/Loader.tsx +3 -24
- package/src/components/agent/McpDialog.tsx +64 -137
- package/src/components/agent/Message.tsx +2 -21
- package/src/components/agent/ModelSelect.tsx +33 -62
- package/src/components/agent/PermissionModeSelect.tsx +21 -79
- package/src/components/agent/PermissionPrompt.tsx +59 -63
- package/src/components/agent/ProjectIcon.tsx +228 -93
- package/src/components/agent/PromptTokenText.tsx +10 -16
- package/src/components/agent/QuestionPrompt.tsx +43 -64
- package/src/components/agent/Reasoning.tsx +13 -16
- package/src/components/agent/Response.tsx +4 -6
- package/src/components/agent/Scrubber.tsx +31 -80
- package/src/components/agent/SessionBrowser.tsx +64 -243
- package/src/components/agent/SessionEmptyState.tsx +10 -40
- package/src/components/agent/SessionInfoDialog.tsx +46 -78
- package/src/components/agent/SessionItem.tsx +53 -320
- package/src/components/agent/SessionList.tsx +23 -39
- package/src/components/agent/SessionPanel.tsx +345 -923
- package/src/components/agent/SessionStatusIcon.tsx +14 -28
- package/src/components/agent/SessionSteps.tsx +34 -187
- package/src/components/agent/SessionWorkspace.tsx +108 -215
- package/src/components/agent/SkillsDialog.tsx +35 -80
- package/src/components/agent/StatusBar.tsx +51 -136
- package/src/components/agent/SubagentStrip.tsx +12 -77
- package/src/components/agent/ToolCallCard.tsx +94 -135
- package/src/components/agent/Transcript.tsx +103 -1288
- package/src/components/agent/TranscriptItemView.tsx +155 -0
- package/src/components/agent/TranscriptRows.tsx +418 -0
- package/src/components/agent/UsageDialog.tsx +7 -29
- package/src/components/agent/UsageMeters.tsx +18 -73
- package/src/components/agent/pulse.tsx +9 -32
- package/src/components/agent/scrubber-marks.ts +33 -103
- package/src/components/agent/status.ts +1 -5
- package/src/components/agent/tool-result-fetch.tsx +3 -25
- package/src/components/agent/tool-result-image.tsx +32 -111
- package/src/components/agent/transcript-rows.ts +41 -118
- package/src/components/agent/transcript-variant.tsx +3 -80
- package/src/components/agent/use-height-epoch.ts +10 -24
- package/src/components/agent/use-path-links.ts +45 -63
- package/src/components/agent/use-subagent-frame.ts +106 -0
- package/src/components/agent/use-transcript-jumps.ts +49 -107
- package/src/components/prompt-area/animated-placeholder.tsx +6 -5
- package/src/components/prompt-area/clipboard-helpers.ts +26 -21
- package/src/components/prompt-area/cursor-helpers.ts +79 -37
- package/src/components/prompt-area/dom-helpers.ts +97 -69
- package/src/components/prompt-area/file-strip.tsx +90 -80
- package/src/components/prompt-area/html-to-markdown.ts +67 -46
- package/src/components/prompt-area/image-strip.tsx +8 -18
- package/src/components/prompt-area/index.ts +2 -15
- package/src/components/prompt-area/prompt-area-engine.ts +100 -115
- package/src/components/prompt-area/prompt-area-list-ops.ts +66 -59
- package/src/components/prompt-area/prompt-area.tsx +31 -38
- package/src/components/prompt-area/remove-button.tsx +3 -9
- package/src/components/prompt-area/segment-helpers.ts +4 -12
- package/src/components/prompt-area/trigger-popover.tsx +15 -27
- package/src/components/prompt-area/trigger-presets.ts +0 -10
- package/src/components/prompt-area/types.ts +3 -8
- package/src/components/prompt-area/use-chip-editing.ts +305 -0
- package/src/components/prompt-area/use-markdown-mode.ts +7 -17
- package/src/components/prompt-area/use-prompt-area-events.ts +80 -86
- package/src/components/prompt-area/use-prompt-area-keydown.ts +598 -0
- package/src/components/prompt-area/use-prompt-area-state.ts +5 -23
- package/src/components/prompt-area/use-prompt-area.ts +170 -896
- package/src/components/prompt-area/use-trigger-search.ts +21 -19
- package/src/components/terminal/PermissionPrompt.tsx +56 -59
- package/src/components/terminal/QuestionPrompt.tsx +28 -79
- package/src/components/terminal/StatusLine.tsx +13 -63
- package/src/components/terminal/TerminalTranscript.tsx +44 -160
- package/src/components/terminal/affordances.tsx +68 -98
- package/src/components/terminal/blocks.ts +20 -156
- package/src/components/terminal/diff.tsx +19 -67
- package/src/components/terminal/height.ts +163 -313
- package/src/components/terminal/image-box.ts +0 -44
- package/src/components/terminal/items.tsx +150 -305
- package/src/components/terminal/markdown.tsx +36 -95
- package/src/components/terminal/press.tsx +26 -53
- package/src/components/terminal/prompt.tsx +40 -170
- package/src/components/terminal/result-preview.ts +8 -56
- package/src/components/terminal/row.tsx +11 -98
- package/src/components/terminal/scrubber.tsx +108 -401
- package/src/components/terminal/surface.tsx +6 -54
- package/src/components/terminal/todos.ts +66 -0
- package/src/components/terminal/tool-run.ts +21 -192
- package/src/components/ui/AlertDialog.tsx +8 -23
- package/src/components/ui/Badge.tsx +18 -29
- package/src/components/ui/Button.tsx +7 -15
- package/src/components/ui/Card.tsx +2 -13
- package/src/components/ui/CodeBlock.tsx +10 -37
- package/src/components/ui/CopyButton.tsx +10 -23
- package/src/components/ui/Dialog.tsx +23 -36
- package/src/components/ui/Empty.tsx +6 -29
- package/src/components/ui/Input.tsx +14 -16
- package/src/components/ui/Menu.tsx +6 -18
- package/src/components/ui/PortalScope.tsx +1 -21
- package/src/components/ui/ProgressRing.tsx +8 -19
- package/src/components/ui/Select.tsx +18 -31
- package/src/components/ui/Sonner.tsx +1 -2
- package/src/components/ui/Spinner.tsx +1 -1
- package/src/components/ui/Splitter.tsx +30 -64
- package/src/components/ui/Textarea.tsx +2 -5
- package/src/components/ui/Tooltip.tsx +3 -13
- package/src/format.ts +0 -9
- package/src/index.ts +16 -113
- package/src/lib/clipboard.ts +8 -28
- package/src/lib/css.ts +8 -0
- package/src/lib/format.ts +101 -86
- package/src/lib/plan-request.ts +16 -0
- package/src/lib/status.ts +45 -95
- package/src/lib/tool-icon.ts +39 -47
- package/src/lib/utils.ts +1 -3
- package/src/styles/terminal.css +81 -49
- package/src/styles/theme.css +136 -19
- package/src/workspace.ts +1 -24
- package/build/SessionPanel-13l25ubU.d.mts +0 -609
- package/build/SessionPanel-DgwjH4Ve.mjs.map +0 -1
- package/build/format-ljc3lKpA.d.mts +0 -59
- package/build/status-BE-zg88x.mjs.map +0 -1
|
@@ -3,10 +3,6 @@
|
|
|
3
3
|
import { useCallback, useEffect, useRef, useState } from 'react'
|
|
4
4
|
import type { TriggerConfig, TriggerSuggestion } from './types.ts'
|
|
5
5
|
|
|
6
|
-
// ---------------------------------------------------------------------------
|
|
7
|
-
// Types
|
|
8
|
-
// ---------------------------------------------------------------------------
|
|
9
|
-
|
|
10
6
|
type UseTriggerSearchReturn = {
|
|
11
7
|
suggestions: TriggerSuggestion[]
|
|
12
8
|
suggestionsLoading: boolean
|
|
@@ -17,10 +13,6 @@ type UseTriggerSearchReturn = {
|
|
|
17
13
|
reset: () => void
|
|
18
14
|
}
|
|
19
15
|
|
|
20
|
-
// ---------------------------------------------------------------------------
|
|
21
|
-
// Hook
|
|
22
|
-
// ---------------------------------------------------------------------------
|
|
23
|
-
|
|
24
16
|
/**
|
|
25
17
|
* Manages async trigger search lifecycle: debouncing, AbortController
|
|
26
18
|
* cancellation, race-condition prevention, loading/error state.
|
|
@@ -35,25 +27,28 @@ export function useTriggerSearch(): UseTriggerSearchReturn {
|
|
|
35
27
|
|
|
36
28
|
// Version counter – belt-and-suspenders alongside AbortController
|
|
37
29
|
const searchVersion = useRef(0)
|
|
38
|
-
// AbortController for cancelling in-flight async searches
|
|
39
30
|
const abortController = useRef<AbortController | null>(null)
|
|
40
|
-
// Debounce timer for search queries
|
|
41
31
|
const debounceTimer = useRef<ReturnType<typeof setTimeout> | null>(null)
|
|
42
32
|
|
|
43
33
|
const reset = useCallback(() => {
|
|
44
34
|
abortController.current?.abort()
|
|
45
|
-
if (debounceTimer.current)
|
|
35
|
+
if (debounceTimer.current) {
|
|
36
|
+
clearTimeout(debounceTimer.current)
|
|
37
|
+
}
|
|
46
38
|
setSuggestions([])
|
|
47
39
|
setSuggestionsLoading(false)
|
|
48
40
|
setSuggestionsError(null)
|
|
49
41
|
}, [])
|
|
50
42
|
|
|
51
43
|
const search = useCallback((query: string, config: TriggerConfig) => {
|
|
52
|
-
if (!config.onSearch)
|
|
44
|
+
if (!config.onSearch) {
|
|
45
|
+
return
|
|
46
|
+
}
|
|
53
47
|
|
|
54
|
-
// Cancel any previous in-flight request and pending debounce
|
|
55
48
|
abortController.current?.abort()
|
|
56
|
-
if (debounceTimer.current)
|
|
49
|
+
if (debounceTimer.current) {
|
|
50
|
+
clearTimeout(debounceTimer.current)
|
|
51
|
+
}
|
|
57
52
|
|
|
58
53
|
setSuggestionsLoading(true)
|
|
59
54
|
setSuggestionsError(null)
|
|
@@ -70,14 +65,20 @@ export function useTriggerSearch(): UseTriggerSearchReturn {
|
|
|
70
65
|
if (result instanceof Promise) {
|
|
71
66
|
void result.then(
|
|
72
67
|
(items) => {
|
|
73
|
-
if (controller.signal.aborted || searchVersion.current !== version)
|
|
68
|
+
if (controller.signal.aborted || searchVersion.current !== version) {
|
|
69
|
+
return
|
|
70
|
+
}
|
|
74
71
|
setSuggestions(items)
|
|
75
72
|
setSuggestionsLoading(false)
|
|
76
73
|
},
|
|
77
74
|
(error: unknown) => {
|
|
78
|
-
if (controller.signal.aborted || searchVersion.current !== version)
|
|
75
|
+
if (controller.signal.aborted || searchVersion.current !== version) {
|
|
76
|
+
return
|
|
77
|
+
}
|
|
79
78
|
// Silently ignore AbortError (expected when superseded)
|
|
80
|
-
if (error instanceof DOMException && error.name === 'AbortError')
|
|
79
|
+
if (error instanceof DOMException && error.name === 'AbortError') {
|
|
80
|
+
return
|
|
81
|
+
}
|
|
81
82
|
setSuggestionsError(error instanceof Error ? error.message : 'Search failed')
|
|
82
83
|
setSuggestionsLoading(false)
|
|
83
84
|
onSearchError?.(error)
|
|
@@ -97,11 +98,12 @@ export function useTriggerSearch(): UseTriggerSearchReturn {
|
|
|
97
98
|
}
|
|
98
99
|
}, [])
|
|
99
100
|
|
|
100
|
-
// Clean up on unmount
|
|
101
101
|
useEffect(() => {
|
|
102
102
|
return () => {
|
|
103
103
|
abortController.current?.abort()
|
|
104
|
-
if (debounceTimer.current)
|
|
104
|
+
if (debounceTimer.current) {
|
|
105
|
+
clearTimeout(debounceTimer.current)
|
|
106
|
+
}
|
|
105
107
|
}
|
|
106
108
|
}, [])
|
|
107
109
|
|
|
@@ -1,41 +1,22 @@
|
|
|
1
1
|
import { useState } from 'react'
|
|
2
2
|
import type { PermissionRequest } from '@workerdeck/protocol'
|
|
3
3
|
import { toolInputPreview } from '../../lib/format.ts'
|
|
4
|
+
import { planFromRequest } from '../../lib/plan-request.ts'
|
|
4
5
|
import { TerminalDiff, previewPatch } from './diff.tsx'
|
|
6
|
+
import { TerminalMarkdown } from './markdown.tsx'
|
|
5
7
|
import { Choices, Hint, PromptInput, PromptTitle, Rule } from './prompt.tsx'
|
|
6
8
|
import { Blank, Row } from './row.tsx'
|
|
7
9
|
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
* the change itself, a dashed rule, the question, numbered answers, and the keys.
|
|
11
|
-
*
|
|
12
|
-
* Two things it does that the card version does not, both taken from the CLI:
|
|
13
|
-
*
|
|
14
|
-
* - **It shows the change, not the payload.** For a file edit, `old_string` and
|
|
15
|
-
* `new_string` are a diff — so it renders one, rather than the JSON input a
|
|
16
|
-
* reader would have to diff in their head. Without line numbers, necessarily:
|
|
17
|
-
* the edit has not happened and this client has never read the file (see
|
|
18
|
-
* {@link previewPatch}). Everything else falls back to the input.
|
|
19
|
-
* - **The heading is the engine's own sentence.** `displayName` ("Edit file")
|
|
20
|
-
* over `title`, never "wants to use {tool}" composed here — for codex an
|
|
21
|
-
* approval is an *escalation after a sandbox refusal*, and the runner has
|
|
22
|
-
* already written the sentence that says so.
|
|
23
|
-
*/
|
|
10
|
+
const PLAN_SCROLL = { maxHeight: 'min(24rem, 50vh)', overflowY: 'auto' } as const
|
|
11
|
+
|
|
24
12
|
export interface TerminalPermissionPromptProps {
|
|
25
13
|
request: PermissionRequest
|
|
26
14
|
onApprove: (requestId: string) => void
|
|
27
|
-
/** `message` is fed back to the agent, which can then try something else;
|
|
28
|
-
* `interrupt` also stops the turn. */
|
|
29
15
|
onDeny: (requestId: string, message?: string, interrupt?: boolean) => void
|
|
30
16
|
className?: string
|
|
31
17
|
}
|
|
32
18
|
|
|
33
|
-
export function TerminalPermissionPrompt({
|
|
34
|
-
request,
|
|
35
|
-
onApprove,
|
|
36
|
-
onDeny,
|
|
37
|
-
className,
|
|
38
|
-
}: TerminalPermissionPromptProps) {
|
|
19
|
+
export function TerminalPermissionPrompt({ request, onApprove, onDeny, className }: TerminalPermissionPromptProps) {
|
|
39
20
|
const [focused, setFocused] = useState(0)
|
|
40
21
|
const [denying, setDenying] = useState(false)
|
|
41
22
|
const [reason, setReason] = useState('')
|
|
@@ -47,47 +28,61 @@ export function TerminalPermissionPrompt({
|
|
|
47
28
|
setDenying(false)
|
|
48
29
|
}
|
|
49
30
|
|
|
50
|
-
const
|
|
51
|
-
const
|
|
52
|
-
const
|
|
53
|
-
|
|
54
|
-
// what this is about, which for a Bash approval is the command itself.
|
|
31
|
+
const plan = planFromRequest(request)
|
|
32
|
+
const patch = plan ? undefined : previewPatch(request.input)
|
|
33
|
+
const summary = plan ? '' : toolInputPreview(request.input)
|
|
34
|
+
const heading = plan ? 'Plan ready for review' : (request.displayName ?? request.title ?? 'Permission needed')
|
|
55
35
|
const subject = patch?.path ?? (summary || undefined)
|
|
56
36
|
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
37
|
+
const reasonInput = denying ? (
|
|
38
|
+
<PromptInput
|
|
39
|
+
value={reason}
|
|
40
|
+
onChange={setReason}
|
|
41
|
+
onSubmit={() => deny(false)}
|
|
42
|
+
onCancel={() => setDenying(false)}
|
|
43
|
+
placeholder={
|
|
44
|
+
plan
|
|
45
|
+
? 'What should change? (optional) — the agent keeps planning and reads this'
|
|
46
|
+
: 'Reason (optional) — the agent reads this and can try something else'
|
|
47
|
+
}
|
|
48
|
+
/>
|
|
49
|
+
) : undefined
|
|
50
|
+
|
|
51
|
+
const options = plan
|
|
52
|
+
? [
|
|
53
|
+
{ key: 'allow', label: 'Approve plan' },
|
|
54
|
+
{ key: 'deny', label: 'Keep planning — tell it what to change', detail: reasonInput },
|
|
55
|
+
{ key: 'stop', label: 'No, and stop the turn', danger: true },
|
|
56
|
+
]
|
|
57
|
+
: [
|
|
58
|
+
{ key: 'allow', label: 'Yes' },
|
|
59
|
+
{ key: 'deny', label: 'No, and tell the agent what to do differently', detail: reasonInput },
|
|
60
|
+
{ key: 'stop', label: 'No, and stop the turn', danger: true },
|
|
61
|
+
]
|
|
76
62
|
|
|
77
63
|
return (
|
|
78
64
|
<div
|
|
79
|
-
data-slot=
|
|
65
|
+
data-slot="permission-prompt"
|
|
80
66
|
className={className}
|
|
81
67
|
onKeyDown={(event) => {
|
|
82
68
|
if (event.key === 'Escape') {
|
|
83
69
|
event.preventDefault()
|
|
84
70
|
deny(false)
|
|
85
71
|
}
|
|
86
|
-
}}
|
|
72
|
+
}}
|
|
73
|
+
>
|
|
87
74
|
<Rule />
|
|
88
75
|
<PromptTitle title={heading} subject={subject} />
|
|
89
76
|
<Blank />
|
|
90
|
-
{
|
|
77
|
+
{plan ? (
|
|
78
|
+
<>
|
|
79
|
+
<div style={PLAN_SCROLL}>
|
|
80
|
+
<TerminalMarkdown>{plan}</TerminalMarkdown>
|
|
81
|
+
</div>
|
|
82
|
+
<Blank />
|
|
83
|
+
<Rule dashed />
|
|
84
|
+
</>
|
|
85
|
+
) : patch ? (
|
|
91
86
|
<>
|
|
92
87
|
<TerminalDiff patch={patch} />
|
|
93
88
|
<Blank />
|
|
@@ -95,14 +90,12 @@ export function TerminalPermissionPrompt({
|
|
|
95
90
|
</>
|
|
96
91
|
) : request.description ? (
|
|
97
92
|
<>
|
|
98
|
-
<Row tone=
|
|
93
|
+
<Row tone="dim">{request.description}</Row>
|
|
99
94
|
<Blank />
|
|
100
95
|
</>
|
|
101
96
|
) : null}
|
|
102
|
-
{request.decisionReason ? <Row tone=
|
|
103
|
-
<Row>
|
|
104
|
-
{request.title ?? `Do you want to proceed?`}
|
|
105
|
-
</Row>
|
|
97
|
+
{request.decisionReason ? <Row tone="faint">{request.decisionReason}</Row> : null}
|
|
98
|
+
<Row>{plan ? 'Ready to implement this plan?' : (request.title ?? `Do you want to proceed?`)}</Row>
|
|
106
99
|
<Choices
|
|
107
100
|
label={heading}
|
|
108
101
|
options={options}
|
|
@@ -110,9 +103,13 @@ export function TerminalPermissionPrompt({
|
|
|
110
103
|
onFocus={setFocused}
|
|
111
104
|
active={!denying}
|
|
112
105
|
onChoose={(index) => {
|
|
113
|
-
if (index === 0)
|
|
114
|
-
|
|
115
|
-
else
|
|
106
|
+
if (index === 0) {
|
|
107
|
+
onApprove(request.id)
|
|
108
|
+
} else if (index === 1) {
|
|
109
|
+
setDenying(true)
|
|
110
|
+
} else {
|
|
111
|
+
deny(true)
|
|
112
|
+
}
|
|
116
113
|
}}
|
|
117
114
|
/>
|
|
118
115
|
<Blank />
|
|
@@ -4,56 +4,29 @@ import { parseUserQuestions } from '../agent/QuestionPrompt.tsx'
|
|
|
4
4
|
import { Box, Choices, Hint, PromptInput, Rule, TabStrip, type Choice } from './prompt.tsx'
|
|
5
5
|
import { Blank, Ink, Row } from './row.tsx'
|
|
6
6
|
|
|
7
|
-
/**
|
|
8
|
-
* The AskUserQuestion form, in the CLI's shape: **one question at a time**,
|
|
9
|
-
* behind a strip of chips, ending in a review step.
|
|
10
|
-
*
|
|
11
|
-
* The card version stacks every question on screen at once, which is right for a
|
|
12
|
-
* dialog and wrong here for a reason worth stating: a terminal form is answered
|
|
13
|
-
* with the keyboard, and a stacked form has no answer to "where does `Tab` go" —
|
|
14
|
-
* it has as many focus targets as there are options across every question. One
|
|
15
|
-
* question at a time makes the keys unambiguous (`↑↓` within, `Tab` between,
|
|
16
|
-
* `Enter` to take), and it is why the chips exist: something has to say that
|
|
17
|
-
* answering the first of three is not finishing.
|
|
18
|
-
*
|
|
19
|
-
* The review step is the other half of that. Answers given one screen at a time
|
|
20
|
-
* are answers you cannot see together, so the last chip shows all of them and
|
|
21
|
-
* asks once more before they go back to the model.
|
|
22
|
-
*/
|
|
23
|
-
|
|
24
7
|
type Selection = { labels: string[]; other: string; otherActive: boolean }
|
|
25
8
|
|
|
26
9
|
const EMPTY: Selection = { labels: [], other: '', otherActive: false }
|
|
27
10
|
|
|
28
|
-
/** A question's answer: chosen label(s), comma-joined, with any free-text
|
|
29
|
-
* "Other" appended — the shape the CLI's own UI puts in `updatedInput.answers`. */
|
|
30
11
|
function answerFor(selection: Selection): string {
|
|
31
12
|
const parts = [...selection.labels]
|
|
32
|
-
if (selection.otherActive && selection.other.trim())
|
|
13
|
+
if (selection.otherActive && selection.other.trim()) {
|
|
14
|
+
parts.push(selection.other.trim())
|
|
15
|
+
}
|
|
33
16
|
return parts.join(', ')
|
|
34
17
|
}
|
|
35
18
|
|
|
36
19
|
export interface TerminalQuestionPromptProps {
|
|
37
|
-
/** A pending permission whose toolName is 'AskUserQuestion'. */
|
|
38
20
|
request: PermissionRequest
|
|
39
|
-
/** Allow the tool with `updatedInput` (the original input plus `answers`). */
|
|
40
21
|
onAnswer: (requestId: string, updatedInput: Record<string, unknown>) => void
|
|
41
|
-
/** Deny the tool — the model proceeds without an answer. */
|
|
42
22
|
onDismiss: (requestId: string, message?: string) => void
|
|
43
23
|
className?: string
|
|
44
24
|
}
|
|
45
25
|
|
|
46
|
-
export function TerminalQuestionPrompt({
|
|
47
|
-
request,
|
|
48
|
-
onAnswer,
|
|
49
|
-
onDismiss,
|
|
50
|
-
className,
|
|
51
|
-
}: TerminalQuestionPromptProps) {
|
|
26
|
+
export function TerminalQuestionPrompt({ request, onAnswer, onDismiss, className }: TerminalQuestionPromptProps) {
|
|
52
27
|
const questions = parseUserQuestions(request.input)
|
|
53
28
|
const [selections, setSelections] = useState<Selection[]>(() => questions.map(() => EMPTY))
|
|
54
29
|
const [cursors, setCursors] = useState<number[]>(() => questions.map(() => 0))
|
|
55
|
-
// Tabs are the questions plus the review step, so `questions.length` is the
|
|
56
|
-
// review — one index space, which is what makes Tab a single `+1`.
|
|
57
30
|
const [tab, setTab] = useState(0)
|
|
58
31
|
const [reviewCursor, setReviewCursor] = useState(0)
|
|
59
32
|
|
|
@@ -65,19 +38,18 @@ export function TerminalQuestionPrompt({
|
|
|
65
38
|
const update = (index: number, patch: Partial<Selection>) =>
|
|
66
39
|
setSelections((prev) => prev.map((s, i) => (i === index ? { ...s, ...patch } : s)))
|
|
67
40
|
|
|
68
|
-
// Functional, and it has to be: reading `selections` from the render closure
|
|
69
|
-
// means two toggles in one tick both compute from the same base and the second
|
|
70
|
-
// silently drops the first. A multi-select is exactly where that happens.
|
|
71
41
|
const toggle = (index: number, label: string, multiSelect: boolean) => {
|
|
72
42
|
setSelections((prev) =>
|
|
73
43
|
prev.map((current, i) => {
|
|
74
|
-
if (i !== index)
|
|
75
|
-
|
|
44
|
+
if (i !== index) {
|
|
45
|
+
return current
|
|
46
|
+
}
|
|
47
|
+
if (!multiSelect) {
|
|
48
|
+
return { ...current, labels: [label], otherActive: false }
|
|
49
|
+
}
|
|
76
50
|
return {
|
|
77
51
|
...current,
|
|
78
|
-
labels: current.labels.includes(label)
|
|
79
|
-
? current.labels.filter((l) => l !== label)
|
|
80
|
-
: [...current.labels, label],
|
|
52
|
+
labels: current.labels.includes(label) ? current.labels.filter((l) => l !== label) : [...current.labels, label],
|
|
81
53
|
}
|
|
82
54
|
}),
|
|
83
55
|
)
|
|
@@ -93,12 +65,11 @@ export function TerminalQuestionPrompt({
|
|
|
93
65
|
|
|
94
66
|
const dismiss = () => onDismiss(request.id, 'Question dismissed by user')
|
|
95
67
|
|
|
96
|
-
const move = (delta: number) =>
|
|
97
|
-
setTab((current) => Math.min(questions.length, Math.max(0, current + delta)))
|
|
68
|
+
const move = (delta: number) => setTab((current) => Math.min(questions.length, Math.max(0, current + delta)))
|
|
98
69
|
|
|
99
70
|
return (
|
|
100
71
|
<div
|
|
101
|
-
data-slot=
|
|
72
|
+
data-slot="question-prompt"
|
|
102
73
|
className={className}
|
|
103
74
|
onKeyDown={(event) => {
|
|
104
75
|
if (event.key === 'Escape') {
|
|
@@ -110,7 +81,8 @@ export function TerminalQuestionPrompt({
|
|
|
110
81
|
event.preventDefault()
|
|
111
82
|
move(event.shiftKey ? -1 : 1)
|
|
112
83
|
}
|
|
113
|
-
}}
|
|
84
|
+
}}
|
|
85
|
+
>
|
|
114
86
|
<Rule />
|
|
115
87
|
<TabStrip
|
|
116
88
|
tabs={[
|
|
@@ -137,10 +109,7 @@ export function TerminalQuestionPrompt({
|
|
|
137
109
|
/>
|
|
138
110
|
) : (
|
|
139
111
|
<QuestionStep
|
|
140
|
-
// Keyed by question
|
|
141
|
-
// step across a Tab and the option list never re-arms its focus, so
|
|
142
|
-
// the keyboard lands on nothing and the next `3` or `Esc` goes
|
|
143
|
-
// nowhere. Remounting is also what re-runs the focus takeover.
|
|
112
|
+
// Keyed by question and load-bearing: without it React reuses the step across a Tab and the option list never re-arms its focus.
|
|
144
113
|
key={tab}
|
|
145
114
|
question={questions[tab]!}
|
|
146
115
|
selection={selection}
|
|
@@ -153,9 +122,7 @@ export function TerminalQuestionPrompt({
|
|
|
153
122
|
/>
|
|
154
123
|
)}
|
|
155
124
|
<Blank />
|
|
156
|
-
<Hint>
|
|
157
|
-
Enter to select · ↑/↓ to navigate · Tab to switch questions · Esc to cancel
|
|
158
|
-
</Hint>
|
|
125
|
+
<Hint>Enter to select · ↑/↓ to navigate · Tab to switch questions · Esc to cancel</Hint>
|
|
159
126
|
</div>
|
|
160
127
|
)
|
|
161
128
|
}
|
|
@@ -188,21 +155,14 @@ function QuestionStep({
|
|
|
188
155
|
key: option.label,
|
|
189
156
|
label: option.label,
|
|
190
157
|
description: option.description,
|
|
191
|
-
// Markers only where there is state to keep: a multi-select accumulates
|
|
192
|
-
// and has to show what is in the set, a one-of answers itself by being
|
|
193
|
-
// chosen and moves on — a column of empty radios in front of it would be
|
|
194
|
-
// three characters of nothing on every row. The CLI draws it the same way.
|
|
195
158
|
checked: multiSelect ? selected : undefined,
|
|
196
159
|
marker: 'check',
|
|
197
160
|
selected: !multiSelect && selected,
|
|
198
|
-
// A preview shows on **focus**, not only on selection: walking a list
|
|
199
|
-
// with the arrow keys, "what does this one look like" is the question
|
|
200
|
-
// the cursor is asking.
|
|
201
161
|
detail:
|
|
202
162
|
option.preview && cursor === index ? (
|
|
203
163
|
<Box>
|
|
204
164
|
{option.preview.split('\n').map((line, row) => (
|
|
205
|
-
<Row key={row} columns={0} tone=
|
|
165
|
+
<Row key={row} columns={0} tone="dim">
|
|
206
166
|
{line || ' '}
|
|
207
167
|
</Row>
|
|
208
168
|
))}
|
|
@@ -213,8 +173,6 @@ function QuestionStep({
|
|
|
213
173
|
{
|
|
214
174
|
key: '__other',
|
|
215
175
|
label: 'Other…',
|
|
216
|
-
// Always markered, in both kinds: this row is a *mode* (the field is open
|
|
217
|
-
// or it isn't), which the reader cannot see any other way.
|
|
218
176
|
checked: selection.otherActive,
|
|
219
177
|
marker: 'check' as const,
|
|
220
178
|
detail: selection.otherActive ? (
|
|
@@ -223,20 +181,17 @@ function QuestionStep({
|
|
|
223
181
|
onChange={(value) => onOther({ other: value })}
|
|
224
182
|
onSubmit={onAdvance}
|
|
225
183
|
onCancel={() => onOther({ otherActive: false, other: '' })}
|
|
226
|
-
placeholder=
|
|
184
|
+
placeholder="Type your own answer"
|
|
227
185
|
/>
|
|
228
186
|
) : undefined,
|
|
229
187
|
},
|
|
230
|
-
// Multi-select has no natural end — every toggle leaves the list where it
|
|
231
|
-
// was — so it needs a row that says "done with this one". A one-of answers
|
|
232
|
-
// itself by being chosen and advances on its own.
|
|
233
188
|
...(multiSelect ? [{ key: '__next', label: 'Submit' }] : []),
|
|
234
189
|
{ key: '__chat', label: 'Chat about this' },
|
|
235
190
|
]
|
|
236
191
|
|
|
237
192
|
return (
|
|
238
193
|
<>
|
|
239
|
-
<Row bold tone=
|
|
194
|
+
<Row bold tone="bright">
|
|
240
195
|
{question.question}
|
|
241
196
|
</Row>
|
|
242
197
|
<Blank />
|
|
@@ -249,9 +204,9 @@ function QuestionStep({
|
|
|
249
204
|
onChoose={(index) => {
|
|
250
205
|
if (index < question.options.length) {
|
|
251
206
|
onToggle(question.options[index]!.label)
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
207
|
+
if (!multiSelect) {
|
|
208
|
+
onAdvance()
|
|
209
|
+
}
|
|
255
210
|
return
|
|
256
211
|
}
|
|
257
212
|
if (index === question.options.length) {
|
|
@@ -269,7 +224,6 @@ function QuestionStep({
|
|
|
269
224
|
)
|
|
270
225
|
}
|
|
271
226
|
|
|
272
|
-
/** The last chip: every answer together, and one more chance to change it. */
|
|
273
227
|
function ReviewStep({
|
|
274
228
|
questions,
|
|
275
229
|
answers,
|
|
@@ -289,30 +243,25 @@ function ReviewStep({
|
|
|
289
243
|
}) {
|
|
290
244
|
return (
|
|
291
245
|
<>
|
|
292
|
-
<Row bold tone=
|
|
246
|
+
<Row bold tone="bright">
|
|
293
247
|
Review your answers
|
|
294
248
|
</Row>
|
|
295
249
|
<Blank />
|
|
296
250
|
{questions.map((question, index) => (
|
|
297
251
|
<div key={index}>
|
|
298
|
-
<Row glyph=
|
|
252
|
+
<Row glyph="●" glyphTone="dim">
|
|
299
253
|
{question.question}
|
|
300
254
|
</Row>
|
|
301
|
-
<Row indent={1} glyph=
|
|
302
|
-
<Ink tone={answers[index] ? 'green' : 'faint'}>
|
|
303
|
-
{answers[index] || 'not answered'}
|
|
304
|
-
</Ink>
|
|
255
|
+
<Row indent={1} glyph="→" glyphTone="green">
|
|
256
|
+
<Ink tone={answers[index] ? 'green' : 'faint'}>{answers[index] || 'not answered'}</Ink>
|
|
305
257
|
</Row>
|
|
306
258
|
</div>
|
|
307
259
|
))}
|
|
308
260
|
<Blank />
|
|
309
261
|
<Row>{complete ? 'Ready to submit your answers?' : 'Some questions are unanswered.'}</Row>
|
|
310
262
|
<Choices
|
|
311
|
-
label=
|
|
263
|
+
label="Submit answers"
|
|
312
264
|
options={[
|
|
313
|
-
// Offered even when incomplete: an unanswered question is a legitimate
|
|
314
|
-
// answer to give, and the model is told which ones were skipped. What
|
|
315
|
-
// it must not do is *look* finished, which is what the row above says.
|
|
316
265
|
{ key: 'submit', label: complete ? 'Submit answers' : 'Submit anyway' },
|
|
317
266
|
{ key: 'cancel', label: 'Cancel', danger: true },
|
|
318
267
|
]}
|
|
@@ -12,25 +12,6 @@ import {
|
|
|
12
12
|
} from '../../lib/status.ts'
|
|
13
13
|
import { Ink, Row, type Tone } from './row.tsx'
|
|
14
14
|
|
|
15
|
-
/**
|
|
16
|
-
* The session's readings, as one line.
|
|
17
|
-
*
|
|
18
|
-
* The styled bar puts each of these in its own affordance — a progress ring, a
|
|
19
|
-
* badge, a tooltip — which is right for a dashboard and wrong here for the usual
|
|
20
|
-
* reason: a terminal has one line height, and five widgets of five different
|
|
21
|
-
* heights is five different rhythms in a row that is supposed to be quiet. So
|
|
22
|
-
* they are words, `·`-separated, exactly as the CLI's own status line writes
|
|
23
|
-
* them.
|
|
24
|
-
*
|
|
25
|
-
* The readings themselves come from the same helpers the styled bar uses
|
|
26
|
-
* (`lib/status.ts`), and that matters more than it looks: `statusPresentation`
|
|
27
|
-
* is where the rule lives that **a dropped socket outranks the session status**,
|
|
28
|
-
* because a status held over a dead socket is a stale reading being presented as
|
|
29
|
-
* a live one. Re-deriving that here would be a second answer to a question that
|
|
30
|
-
* already has one.
|
|
31
|
-
*/
|
|
32
|
-
|
|
33
|
-
/** Severity → tone. The 80/95 thresholds are `lib/status.ts`'s, not new ones. */
|
|
34
15
|
const SEVERITY_TONE: Record<StatusSeverity, Tone> = {
|
|
35
16
|
none: 'dim',
|
|
36
17
|
warning: 'yellow',
|
|
@@ -40,36 +21,19 @@ const SEVERITY_TONE: Record<StatusSeverity, Tone> = {
|
|
|
40
21
|
export interface TerminalStatusLineProps {
|
|
41
22
|
className?: string
|
|
42
23
|
state: TranscriptState
|
|
43
|
-
/** Plan windows to read from, when they should not be the session's own — the
|
|
44
|
-
* gateway's per-profile state merged over this transcript's. See
|
|
45
|
-
* {@link StatusBarProps.rateLimits}. Absent = `state.rateLimits`. */
|
|
46
24
|
rateLimits?: Record<string, RateLimitInfo>
|
|
47
|
-
/** How the client is doing at reaching the gateway. Wins the status slot when
|
|
48
|
-
* the socket is down — see above. */
|
|
49
25
|
connection?: ConnectionState
|
|
50
|
-
/** Opens the panel that answers each reading's own question. Omit and the
|
|
51
|
-
* reading is text rather than something to press. */
|
|
52
26
|
onOpenStatus?: () => void
|
|
53
27
|
onOpenContext?: () => void
|
|
54
28
|
onOpenUsage?: () => void
|
|
55
29
|
}
|
|
56
30
|
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
onPress,
|
|
62
|
-
label,
|
|
63
|
-
children,
|
|
64
|
-
}: {
|
|
65
|
-
tone?: Tone
|
|
66
|
-
onPress?: () => void
|
|
67
|
-
label?: string
|
|
68
|
-
children: React.ReactNode
|
|
69
|
-
}) {
|
|
70
|
-
if (!onPress) return <Ink tone={tone}>{children}</Ink>
|
|
31
|
+
function Reading({ tone, onPress, label, children }: { tone?: Tone; onPress?: () => void; label?: string; children: React.ReactNode }) {
|
|
32
|
+
if (!onPress) {
|
|
33
|
+
return <Ink tone={tone}>{children}</Ink>
|
|
34
|
+
}
|
|
71
35
|
return (
|
|
72
|
-
<button type=
|
|
36
|
+
<button type="button" className="term-reading" onClick={onPress} title={label}>
|
|
73
37
|
<Ink tone={tone}>{children}</Ink>
|
|
74
38
|
</button>
|
|
75
39
|
)
|
|
@@ -90,22 +54,14 @@ export function TerminalStatusLine({
|
|
|
90
54
|
const model = modelLabel({ model: state.model, models: state.models ?? [] })
|
|
91
55
|
|
|
92
56
|
const parts: React.ReactNode[] = [
|
|
93
|
-
<Reading
|
|
94
|
-
key='status'
|
|
95
|
-
tone={SEVERITY_TONE[presentation.severity]}
|
|
96
|
-
onPress={onOpenStatus}
|
|
97
|
-
label='Session status'>
|
|
57
|
+
<Reading key="status" tone={SEVERITY_TONE[presentation.severity]} onPress={onOpenStatus} label="Session status">
|
|
98
58
|
{presentation.label}
|
|
99
59
|
</Reading>,
|
|
100
60
|
]
|
|
101
61
|
|
|
102
62
|
if (usage) {
|
|
103
63
|
parts.push(
|
|
104
|
-
<Reading
|
|
105
|
-
key='context'
|
|
106
|
-
tone={SEVERITY_TONE[contextSeverity(usage)]}
|
|
107
|
-
onPress={onOpenContext}
|
|
108
|
-
label='Context window'>
|
|
64
|
+
<Reading key="context" tone={SEVERITY_TONE[contextSeverity(usage)]} onPress={onOpenContext} label="Context window">
|
|
109
65
|
{Math.round(usage.percentage)}% ctx ({formatTokens(usage.totalTokens)})
|
|
110
66
|
</Reading>,
|
|
111
67
|
)
|
|
@@ -113,21 +69,15 @@ export function TerminalStatusLine({
|
|
|
113
69
|
|
|
114
70
|
if (window) {
|
|
115
71
|
parts.push(
|
|
116
|
-
<Reading
|
|
117
|
-
key='usage'
|
|
118
|
-
tone={SEVERITY_TONE[meterSeverity(window.info.utilization)]}
|
|
119
|
-
onPress={onOpenUsage}
|
|
120
|
-
label='Plan usage'>
|
|
72
|
+
<Reading key="usage" tone={SEVERITY_TONE[meterSeverity(window.info.utilization)]} onPress={onOpenUsage} label="Plan usage">
|
|
121
73
|
{Math.round(window.info.utilization ?? 0)}% {windowLabel(window.key)}
|
|
122
74
|
</Reading>,
|
|
123
75
|
)
|
|
124
76
|
}
|
|
125
77
|
|
|
126
|
-
// Cost last of the numbers: it is the reading you check, never the one you act
|
|
127
|
-
// on, and nothing about a turn changes because of it.
|
|
128
78
|
if (state.totalCostUsd > 0) {
|
|
129
79
|
parts.push(
|
|
130
|
-
<Ink key=
|
|
80
|
+
<Ink key="cost" tone="faint">
|
|
131
81
|
{formatCost(state.totalCostUsd)}
|
|
132
82
|
</Ink>,
|
|
133
83
|
)
|
|
@@ -135,24 +85,24 @@ export function TerminalStatusLine({
|
|
|
135
85
|
|
|
136
86
|
if (model) {
|
|
137
87
|
parts.push(
|
|
138
|
-
<Ink key=
|
|
88
|
+
<Ink key="model" tone="faint">
|
|
139
89
|
{model}
|
|
140
90
|
</Ink>,
|
|
141
91
|
)
|
|
142
92
|
}
|
|
143
93
|
if (state.permissionMode && state.permissionMode !== 'default') {
|
|
144
94
|
parts.push(
|
|
145
|
-
<Ink key=
|
|
95
|
+
<Ink key="mode" tone="yellow">
|
|
146
96
|
{state.permissionMode}
|
|
147
97
|
</Ink>,
|
|
148
98
|
)
|
|
149
99
|
}
|
|
150
100
|
|
|
151
101
|
return (
|
|
152
|
-
<Row data-slot=
|
|
102
|
+
<Row data-slot="status-line" tone="dim" className={className}>
|
|
153
103
|
{parts.map((part, index) => (
|
|
154
104
|
<span key={index}>
|
|
155
|
-
{index > 0 ? <Ink tone=
|
|
105
|
+
{index > 0 ? <Ink tone="faint"> · </Ink> : null}
|
|
156
106
|
{part}
|
|
157
107
|
</span>
|
|
158
108
|
))}
|