ompchamber 3.1.1 → 3.3.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/dist/client/index.html +1 -1
- package/dist/client/index.js +306 -298
- package/dist/client/static/js/index-bsxxt89j.css +1 -0
- package/dist/client/static/js/{index-qhzsybkn.js → index-qkt4z7x0.js} +658 -658
- package/package.json +4 -3
- package/public/sw.js +54 -5
- package/src/cli/lib/output.js +15 -8
- package/src/client/components/common/Modal.tsx +14 -4
- package/src/client/components/{layout/session-sidebar/SubagentList.tsx → common/subagent-list/index.tsx} +8 -10
- package/src/client/components/layout/session-sidebar/CategoryItem.tsx +6 -30
- package/src/client/components/layout/session-sidebar/Toolbar.tsx +11 -5
- package/src/client/components/layout/session-sidebar/index.tsx +4 -2
- package/src/client/components/mobile/LayoutWrapper.tsx +6 -0
- package/src/client/components/mobile/SessionSidebar.tsx +4 -0
- package/src/client/components/mobile/mobile-right-sidebar/FullEditor.tsx +27 -8
- package/src/client/components/mobile/mobile-session-sidebar/Item.tsx +67 -29
- package/src/client/components/mobile/mobile-session-sidebar/SessionRow.test.ts +84 -0
- package/src/client/components/mobile/mobile-session-sidebar/SessionRow.tsx +59 -12
- package/src/client/components/mobile/mobile-session-sidebar/Toolbar.tsx +21 -1
- package/src/client/components/settings/Modal.tsx +45 -23
- package/src/client/components/settings/SettingsSidebarList.tsx +1 -1
- package/src/client/components/settings/categories/AgentSettings.tsx +45 -28
- package/src/client/components/settings/categories/AppearanceSettings.tsx +3 -0
- package/src/client/components/settings/categories/CommandSettings.tsx +45 -28
- package/src/client/components/settings/categories/McpSettings.tsx +48 -31
- package/src/client/components/settings/categories/ProjectSettings.tsx +34 -19
- package/src/client/components/settings/categories/ProviderSettings.tsx +77 -53
- package/src/client/components/settings/categories/SkillSettings.tsx +34 -17
- package/src/client/components/settings/categories/UsageSettings.tsx +53 -39
- package/src/client/components/settings/categories/appearance-settings/EditorFontSection.tsx +78 -0
- package/src/client/components/settings/categories/behavior-settings/Editor.tsx +7 -7
- package/src/client/components/settings/categories/mcp-settings/ImportModal.tsx +1 -1
- package/src/client/components/settings/categories/mcp-settings/SidebarList.tsx +1 -1
- package/src/client/components/settings/categories/project-settings/SidebarList.tsx +1 -1
- package/src/client/components/settings/categories/provider-settings/AuthSection.tsx +8 -3
- package/src/client/components/settings/categories/provider-settings/DeleteProviderModal.tsx +1 -1
- package/src/client/components/settings/categories/provider-settings/ModelsList.tsx +9 -4
- package/src/client/components/settings/categories/provider-settings/SidebarList.tsx +1 -1
- package/src/client/components/settings/categories/skill-catalog/AddSourceModal.tsx +1 -1
- package/src/client/components/settings/categories/skill-settings/SidebarList.tsx +1 -1
- package/src/client/components/settings/categories/usage-settings/SidebarList.tsx +1 -1
- package/src/client/components/settings/master-detail/index.tsx +68 -0
- package/src/client/components/workspace/SearchPanel.tsx +2 -1
- package/src/client/components/workspace/chat-timeline/SubagentView.tsx +7 -1
- package/src/client/components/workspace/chat-timeline/SystemNotice.tsx +19 -12
- package/src/client/components/workspace/chat-timeline/TaskResultContent.tsx +2 -1
- package/src/client/components/workspace/chat-timeline/index.tsx +1 -0
- package/src/client/components/workspace/chat-timeline/tool-renderers/hashline-patch/index.tsx +2 -1
- package/src/client/components/workspace/chat-timeline/tool-renderers/panels/AstEdit.tsx +2 -1
- package/src/client/components/workspace/chat-timeline/tool-renderers/panels/Bash.tsx +2 -1
- package/src/client/components/workspace/chat-timeline/tool-renderers/panels/Edit.tsx +2 -1
- package/src/client/components/workspace/chat-timeline/tool-renderers/panels/Read.tsx +2 -1
- package/src/client/components/workspace/chat-timeline/tool-renderers/panels/SearchTool.tsx +2 -1
- package/src/client/components/workspace/chat-timeline/tool-renderers/shared/DiffView.tsx +2 -1
- package/src/client/components/workspace/diff-panel/SplitView.tsx +31 -5
- package/src/client/components/workspace/diff-panel/Toolbar.tsx +77 -6
- package/src/client/components/workspace/diff-panel/UnifiedView.tsx +18 -5
- package/src/client/components/workspace/diff-panel/index.tsx +36 -7
- package/src/client/components/workspace/editor/Toolbar.tsx +11 -4
- package/src/client/components/workspace/editor/index.tsx +15 -7
- package/src/client/components/workspace/git-panel/commit-modal/CommitDiffViewer.tsx +2 -1
- package/src/client/components/workspace/terminal-panel/KeyBar.tsx +194 -0
- package/src/client/components/workspace/terminal-panel/RealtimeXtermView.tsx +24 -0
- package/src/client/components/workspace/terminal-panel/index.tsx +98 -3
- package/src/client/components/workspace/terminal-panel/mount.ts +47 -0
- package/src/client/data/theme/terminal.ts +25 -8
- package/src/client/hooks/chat/omp/session-list.tsx +31 -1
- package/src/client/hooks/chat/omp/session-sidebar-controller.ts +7 -1
- package/src/client/hooks/chat/subagent/index.ts +37 -11
- package/src/client/hooks/editor/font.ts +53 -0
- package/src/client/hooks/editor/use-file-editor.ts +1 -1
- package/src/client/hooks/settings/master-detail.ts +33 -0
- package/src/client/hooks/ui/clipboard.ts +17 -0
- package/src/client/hooks/ui/keyboard-inset.test.ts +46 -0
- package/src/client/hooks/ui/keyboard-inset.ts +113 -0
- package/src/client/hooks/ui/text-overflow.test.ts +152 -0
- package/src/client/hooks/ui/text-overflow.ts +78 -0
- package/src/client/hooks/ui/touch-device.ts +46 -0
- package/src/client/hooks/workspace/expanded-sessions.ts +62 -0
- package/src/client/main.tsx +1 -0
- package/src/server/index.ts +4 -3
- package/src/server/lib/assets/conditional.server.test.ts +74 -0
- package/src/server/lib/assets/conditional.server.ts +54 -0
- package/src/server/lib/assets/dev-assets.server.test.ts +44 -0
- package/src/server/lib/assets/dev-assets.server.ts +121 -0
- package/src/server/lib/assets/fonts.server.ts +9 -4
- package/src/server/lib/bundler/build-errors.server.ts +136 -0
- package/src/server/lib/bundler/build-errors.test.ts +103 -0
- package/src/server/lib/bundler/dependencies.test.ts +134 -0
- package/src/server/lib/db/client.ts +5 -1
- package/src/server/lib/fs/git-diff.ts +34 -10
- package/src/server/lib/fs/git-repos.ts +81 -0
- package/src/server/lib/lifecycle/flock.ts +199 -0
- package/src/server/lib/lifecycle/identity.ts +5 -1
- package/src/server/lib/lifecycle/listener.ts +29 -0
- package/src/server/lib/lifecycle/port-guard.ts +18 -3
- package/src/server/lib/lifecycle/proc/darwin.ts +52 -17
- package/src/server/lib/lifecycle/proc/linux.ts +29 -7
- package/src/server/lib/lifecycle/proc/ps.ts +7 -0
- package/src/server/lib/lifecycle/proc/types.ts +17 -0
- package/src/server/lib/lifecycle/proc/win32.ts +5 -0
- package/src/server/lib/observer-ws.ts +150 -0
- package/src/server/lib/omp/rpc/constants.ts +9 -0
- package/src/server/lib/omp/rpc/subagent-liveness.ts +2 -12
- package/src/server/lib/omp/session/file-scan.ts +7 -1
- package/src/server/lib/omp/session/files.ts +95 -96
- package/src/server/lib/omp/session/locator.ts +18 -8
- package/src/server/lib/omp/session/messages.ts +7 -7
- package/src/server/lib/omp/session/scan.ts +108 -0
- package/src/server/lib/omp/session/stats.ts +50 -50
- package/src/server/lib/omp/session/telemetry/scan.ts +89 -7
- package/src/server/lib/omp/session/telemetry-raw.ts +24 -8
- package/src/server/lib/omp/session/usage/aggregate.ts +170 -55
- package/src/server/lib/terminal/runtime.server.ts +3 -0
- package/src/server/lib/terminal/session.server.ts +30 -3
- package/src/server/plugins/shell.server.ts +52 -13
- package/src/server/plugins/ssr.ts +43 -7
- package/src/server/plugins/static.ts +51 -18
- package/src/server/routes/agent/command.test.ts +69 -0
- package/src/server/routes/agent/command.ts +14 -0
- package/src/server/routes/agent/ws.test.ts +159 -0
- package/src/server/routes/agent/ws.ts +22 -97
- package/src/server/routes/btw/ws.ts +22 -95
- package/src/server/routes/fs/git.ts +61 -70
- package/src/server/routes/omp/session-stats.ts +1 -1
- package/src/server/routes/telemetry/tokens.ts +6 -4
- package/src/server/routes/terminal/run.ts +4 -8
- package/src/server/routes/terminal/ws.ts +24 -2
- package/src/shared/lib/chat/omp/agent-events.test.ts +33 -1
- package/src/shared/lib/chat/omp/agent-events.ts +10 -0
- package/src/shared/lib/code/editor/typography.test.ts +93 -0
- package/src/shared/lib/code/editor/typography.ts +86 -0
- package/src/shared/lib/code/language.ts +146 -0
- package/src/shared/lib/code/syntax-highlight.test.ts +2 -1
- package/src/shared/lib/code/syntax-highlight.ts +0 -122
- package/src/shared/lib/fonts/NOTICE.md +44 -0
- package/src/shared/lib/fonts/nerd-symbols.css +55 -0
- package/src/shared/lib/fonts/nerd-symbols.test.ts +134 -0
- package/src/shared/lib/fonts/symbols-nerd-font-mono.woff2 +0 -0
- package/src/shared/lib/fs/diff-parser.test.ts +5 -5
- package/src/shared/lib/fs/diff-parser.ts +67 -42
- package/src/shared/lib/omp/subagent/parse.test.ts +88 -0
- package/src/shared/lib/omp/subagent/parse.ts +17 -0
- package/src/shared/lib/omp/subagent/transcript-client.test.ts +100 -0
- package/src/shared/lib/omp/subagent/transcript-client.ts +17 -1
- package/src/shared/lib/settings/diff.test.ts +1 -1
- package/src/shared/lib/workspace/terminal/keys.test.ts +189 -0
- package/src/shared/lib/workspace/terminal/keys.ts +225 -0
- package/src/shared/types/omp/subagent.ts +6 -0
- package/src/shared/types/settings/state.ts +7 -16
- package/dist/client/static/js/index-5yfre1kw.css +0 -1
|
@@ -6,9 +6,11 @@ import { useSyntaxReady } from '@/client/hooks/ui/syntax-ready';
|
|
|
6
6
|
interface UnifiedViewProps {
|
|
7
7
|
lines: DiffLine[];
|
|
8
8
|
language: string;
|
|
9
|
+
/** Wrap long lines instead of scrolling horizontally. */
|
|
10
|
+
wordWrap: boolean;
|
|
9
11
|
}
|
|
10
12
|
|
|
11
|
-
export function UnifiedView({ lines, language }: UnifiedViewProps) {
|
|
13
|
+
export function UnifiedView({ lines, language, wordWrap }: UnifiedViewProps) {
|
|
12
14
|
const syntaxReady = useSyntaxReady();
|
|
13
15
|
|
|
14
16
|
const htmlByIndex = useMemo(() => {
|
|
@@ -41,8 +43,13 @@ export function UnifiedView({ lines, language }: UnifiedViewProps) {
|
|
|
41
43
|
{/* The room under the last row belongs to the content, not the scroller:
|
|
42
44
|
a scroll container's own bottom padding is not part of its scrollable
|
|
43
45
|
overflow, so the padding has to sit inside it to have any effect. */}
|
|
44
|
-
<div className=
|
|
45
|
-
|
|
46
|
+
<div className={`pb-4 ${wordWrap ? 'w-full' : 'w-max min-w-full'}`}>
|
|
47
|
+
{/* Wrap needs `table-fixed`: auto layout sizes a column by its widest
|
|
48
|
+
cell's UNAMBIGUOUS minimum (`overflow-wrap: break-word` does not
|
|
49
|
+
lower it), so a long line would still widen the table and scroll
|
|
50
|
+
instead of wrapping. Fixed layout takes the width from the row's
|
|
51
|
+
own box, which is what makes the cell's wrap rule effective. */}
|
|
52
|
+
<table className={`border-collapse ${wordWrap ? 'table-fixed w-full' : 'w-max min-w-full'}`}>
|
|
46
53
|
<tbody>
|
|
47
54
|
{lines.map((line, idx) => {
|
|
48
55
|
if (line.type === 'meta') {
|
|
@@ -69,9 +76,13 @@ export function UnifiedView({ lines, language }: UnifiedViewProps) {
|
|
|
69
76
|
: 'hover:bg-ink/[0.03]'
|
|
70
77
|
}`}
|
|
71
78
|
>
|
|
72
|
-
{/* Old line number
|
|
79
|
+
{/* Old line number. Top-aligned when the row wraps, or the
|
|
80
|
+
number would float to the middle of a multi-line cell
|
|
81
|
+
instead of naming its first line. */}
|
|
73
82
|
<td
|
|
74
83
|
className={`w-12 py-0.5 px-2 text-right text-[10px] select-none border-r border-ink/10 font-mono ${
|
|
84
|
+
wordWrap ? 'align-top' : ''
|
|
85
|
+
} ${
|
|
75
86
|
isDel
|
|
76
87
|
? 'bg-error/15 text-error font-semibold'
|
|
77
88
|
: isAdd
|
|
@@ -85,6 +96,8 @@ export function UnifiedView({ lines, language }: UnifiedViewProps) {
|
|
|
85
96
|
{/* New line number */}
|
|
86
97
|
<td
|
|
87
98
|
className={`w-12 py-0.5 px-2 text-right text-[10px] select-none border-r border-ink/10 font-mono ${
|
|
99
|
+
wordWrap ? 'align-top' : ''
|
|
100
|
+
} ${
|
|
88
101
|
isAdd
|
|
89
102
|
? 'bg-success/15 text-success font-semibold'
|
|
90
103
|
: isDel
|
|
@@ -96,7 +109,7 @@ export function UnifiedView({ lines, language }: UnifiedViewProps) {
|
|
|
96
109
|
</td>
|
|
97
110
|
|
|
98
111
|
{/* Change prefix & code text */}
|
|
99
|
-
<td className=
|
|
112
|
+
<td className={`py-0.5 px-3 text-ink pr-6 ${wordWrap ? 'whitespace-pre-wrap break-words align-top' : 'whitespace-pre'}`}>
|
|
100
113
|
<span
|
|
101
114
|
className={`inline-block w-4 text-center select-none font-bold ${
|
|
102
115
|
isAdd ? 'text-success' : isDel ? 'text-error' : 'opacity-0'
|
|
@@ -1,8 +1,8 @@
|
|
|
1
|
-
import { useCallback, useEffect, useRef, useState } from 'preact/hooks';
|
|
1
|
+
import { useCallback, useEffect, useMemo, useRef, useState } from 'preact/hooks';
|
|
2
2
|
import { useSessionUiState } from '@/client/hooks/workspace/session-state';
|
|
3
3
|
import { parseUnifiedDiff } from '@/shared/lib/fs/diff-parser';
|
|
4
|
-
import { getLanguageFromPath } from '@/shared/lib/code/
|
|
5
|
-
import { DiffToolbar, type DiffViewMode } from '@/client/components/workspace/diff-panel/Toolbar';
|
|
4
|
+
import { getLanguageFromPath } from '@/shared/lib/code/language';
|
|
5
|
+
import { DiffToolbar, type DiffContentMode, type DiffViewMode } from '@/client/components/workspace/diff-panel/Toolbar';
|
|
6
6
|
import { UnifiedView } from '@/client/components/workspace/diff-panel/UnifiedView';
|
|
7
7
|
import { SplitView } from '@/client/components/workspace/diff-panel/SplitView';
|
|
8
8
|
import { AlertCircle, RefreshCw } from 'lucide-preact';
|
|
@@ -30,6 +30,8 @@ export function DiffPanel({
|
|
|
30
30
|
}: DiffPanelProps) {
|
|
31
31
|
// Read and persist view mode & whitespace preference in session_ui_state
|
|
32
32
|
const [viewMode, setViewMode] = useSessionUiState<DiffViewMode>('diff.viewMode', 'unified');
|
|
33
|
+
const [contentMode, setContentMode] = useSessionUiState<DiffContentMode>('diff.contentMode', 'full');
|
|
34
|
+
const [wordWrap, setWordWrap] = useSessionUiState<boolean>('diff.wordWrap', true);
|
|
33
35
|
const [ignoreWhitespace, setIgnoreWhitespace] = useSessionUiState<boolean>('diff.ignoreWhitespace', false);
|
|
34
36
|
|
|
35
37
|
const [rawDiff, setRawDiff] = useState<string>('');
|
|
@@ -66,6 +68,11 @@ export function DiffPanel({
|
|
|
66
68
|
staged: staged ? '1' : '0',
|
|
67
69
|
repo: repo || '.',
|
|
68
70
|
});
|
|
71
|
+
// `fullContext=1` makes git emit the whole file rather than the hunks.
|
|
72
|
+
// Only the user's own toggle changes it, so it is safe as a callback
|
|
73
|
+
// dependency (unlike the staged/status copies above, which the action
|
|
74
|
+
// path updates while a fetch of its own is already in flight).
|
|
75
|
+
if (contentMode === 'full') params.set('fullContext', '1');
|
|
69
76
|
if (statusForFetch) params.set('status', statusForFetch);
|
|
70
77
|
if (root) params.set('root', root);
|
|
71
78
|
|
|
@@ -99,7 +106,7 @@ export function DiffPanel({
|
|
|
99
106
|
} finally {
|
|
100
107
|
if (seq === fetchSeqRef.current) setIsLoading(false);
|
|
101
108
|
}
|
|
102
|
-
}, [filePath, repo, root]);
|
|
109
|
+
}, [filePath, repo, root, contentMode]);
|
|
103
110
|
|
|
104
111
|
useEffect(() => {
|
|
105
112
|
setCurrentStatus(status);
|
|
@@ -112,13 +119,31 @@ export function DiffPanel({
|
|
|
112
119
|
void fetchDiff();
|
|
113
120
|
}, [fetchDiff]);
|
|
114
121
|
|
|
115
|
-
|
|
122
|
+
// Full-code mode parses the whole file, not the hunks, so the parse is keyed
|
|
123
|
+
// to its inputs: every render used to re-walk the diff (measured 21 ms on an
|
|
124
|
+
// 80k-line file), for a result that only changes when the diff or the
|
|
125
|
+
// whitespace toggle does.
|
|
126
|
+
// Only the rendered view is built: `views` keeps the shared flush (so the
|
|
127
|
+
// whitespace decision and the counts cannot diverge) while skipping the
|
|
128
|
+
// object graph nobody draws.
|
|
129
|
+
const parsed = useMemo(
|
|
130
|
+
() => parseUnifiedDiff(rawDiff, { ignoreWhitespace, views: viewMode === 'split' ? 'split' : 'unified' }),
|
|
131
|
+
[rawDiff, ignoreWhitespace, viewMode],
|
|
132
|
+
);
|
|
116
133
|
const language = getLanguageFromPath(filePath);
|
|
117
134
|
|
|
118
135
|
const handleSetViewMode = (mode: DiffViewMode) => {
|
|
119
136
|
setViewMode(mode);
|
|
120
137
|
};
|
|
121
138
|
|
|
139
|
+
const handleSetContentMode = (mode: DiffContentMode) => {
|
|
140
|
+
setContentMode(mode);
|
|
141
|
+
};
|
|
142
|
+
|
|
143
|
+
const handleToggleWordWrap = () => {
|
|
144
|
+
setWordWrap(!wordWrap);
|
|
145
|
+
};
|
|
146
|
+
|
|
122
147
|
const handleToggleWhitespace = () => {
|
|
123
148
|
setIgnoreWhitespace(!ignoreWhitespace);
|
|
124
149
|
};
|
|
@@ -218,6 +243,8 @@ export function DiffPanel({
|
|
|
218
243
|
status={currentStatus}
|
|
219
244
|
isStaged={currentStaged}
|
|
220
245
|
viewMode={viewMode}
|
|
246
|
+
contentMode={contentMode}
|
|
247
|
+
wordWrap={wordWrap}
|
|
221
248
|
ignoreWhitespace={ignoreWhitespace}
|
|
222
249
|
additions={parsed.additions}
|
|
223
250
|
deletions={parsed.deletions}
|
|
@@ -225,6 +252,8 @@ export function DiffPanel({
|
|
|
225
252
|
isCopied={isCopied}
|
|
226
253
|
isBusy={isBusy}
|
|
227
254
|
onSetViewMode={handleSetViewMode}
|
|
255
|
+
onSetContentMode={handleSetContentMode}
|
|
256
|
+
onToggleWordWrap={handleToggleWordWrap}
|
|
228
257
|
onToggleWhitespace={handleToggleWhitespace}
|
|
229
258
|
onStageUnstage={handleStageUnstage}
|
|
230
259
|
onDiscard={handleDiscard}
|
|
@@ -268,9 +297,9 @@ export function DiffPanel({
|
|
|
268
297
|
</button>
|
|
269
298
|
</div>
|
|
270
299
|
) : viewMode === 'split' ? (
|
|
271
|
-
<SplitView rows={parsed.splitRows} language={language} />
|
|
300
|
+
<SplitView rows={parsed.splitRows} language={language} wordWrap={wordWrap} />
|
|
272
301
|
) : (
|
|
273
|
-
<UnifiedView lines={parsed.lines} language={language} />
|
|
302
|
+
<UnifiedView lines={parsed.lines} language={language} wordWrap={wordWrap} />
|
|
274
303
|
)}
|
|
275
304
|
</div>
|
|
276
305
|
|
|
@@ -68,14 +68,21 @@ export function EditorToolbar({
|
|
|
68
68
|
: 'Save file (Ctrl+S)';
|
|
69
69
|
|
|
70
70
|
return (
|
|
71
|
-
|
|
71
|
+
// `@container`: the path below hides on a NARROW PANEL, not a narrow
|
|
72
|
+
// viewport. At the editor panel's 320px floor the action strip is 273px of
|
|
73
|
+
// `flex-shrink-0`, which left the path a measured 23px — a truncated
|
|
74
|
+
// fragment of a filename is not information, and the tab above already
|
|
75
|
+
// carries it.
|
|
76
|
+
<div className="@container flex items-center justify-between px-3 py-2 border-b border-ink/10 bg-paper flex-shrink-0">
|
|
72
77
|
{/* Left status */}
|
|
73
|
-
<div className="flex items-center space-x-2 text-xs font-mono text-ink/40 min-w-0">
|
|
78
|
+
<div className="hidden @[420px]:flex items-center space-x-2 text-xs font-mono text-ink/40 min-w-0">
|
|
74
79
|
<span className="truncate max-w-[300px]" title={path}>{path}</span>
|
|
75
80
|
</div>
|
|
76
81
|
|
|
77
|
-
{/* Right actions
|
|
78
|
-
|
|
82
|
+
{/* Right actions. `ml-auto` keeps them right-aligned once the path block
|
|
83
|
+
above is hidden, which `justify-between` alone would not do (a single
|
|
84
|
+
child sits at the start). */}
|
|
85
|
+
<div className="flex items-center space-x-1 text-ink/40 flex-shrink-0 ml-auto">
|
|
79
86
|
{!isImage && (
|
|
80
87
|
<div className="flex items-center pr-2 mr-1 border-r border-ink/10">
|
|
81
88
|
<button
|
|
@@ -10,6 +10,13 @@ import { EditorToolbar } from '@/client/components/workspace/editor/Toolbar';
|
|
|
10
10
|
import { FindWidget } from '@/client/components/workspace/editor/FindWidget';
|
|
11
11
|
import { CommandPalette } from '@/client/components/workspace/editor/CommandPalette';
|
|
12
12
|
import { EDITOR_KEY_BINDINGS, isFindBarCommand, type EditorCommand } from '@/shared/lib/code/editor/keymap';
|
|
13
|
+
import {
|
|
14
|
+
EDITOR_DEFAULT_FONT_SIZE,
|
|
15
|
+
EDITOR_LINE_HEIGHT,
|
|
16
|
+
EDITOR_MAX_FONT_SIZE,
|
|
17
|
+
EDITOR_MIN_FONT_SIZE,
|
|
18
|
+
} from '@/shared/lib/code/editor/typography';
|
|
19
|
+
import { useEditorFontStack } from '@/client/hooks/editor/font';
|
|
13
20
|
import type { TextRange } from '@/shared/lib/code/editor/commands';
|
|
14
21
|
import { resolveBinding } from '@/shared/lib/ui/key-binding';
|
|
15
22
|
import { ImageViewer } from '@/client/components/common/image-viewer';
|
|
@@ -45,7 +52,7 @@ export function Editor({
|
|
|
45
52
|
const activeFile = openedFiles.find(f => f.id === activeFileId);
|
|
46
53
|
|
|
47
54
|
const [previewMode, setPreviewMode, previewReady] = useSessionState<Record<string | number, boolean>>('editor.previewMode', {});
|
|
48
|
-
const [zoomLevel, setZoomLevel] = useSessionState<number>('editor.zoomLevel',
|
|
55
|
+
const [zoomLevel, setZoomLevel] = useSessionState<number>('editor.zoomLevel', EDITOR_DEFAULT_FONT_SIZE);
|
|
49
56
|
const [isMaximized, setIsMaximized] = useState(false);
|
|
50
57
|
const [wordWrap, setWordWrap] = useSessionState<boolean>('editor.wordWrap', true);
|
|
51
58
|
/** Extra ⌘D ranges; the surface paints them and the editor edits them by replication. */
|
|
@@ -54,6 +61,7 @@ export function Editor({
|
|
|
54
61
|
const { sessionId } = useSessionStateContext();
|
|
55
62
|
const { isScrolling, handleScroll } = useScrollbarFade();
|
|
56
63
|
const surfaceRef = useRef<CodeSurfaceHandle | null>(null);
|
|
64
|
+
const editorFontFamily = useEditorFontStack();
|
|
57
65
|
|
|
58
66
|
const editor = useFileEditor(activeFile ?? null, {
|
|
59
67
|
// A file that could not be read must report the failure, not substitute
|
|
@@ -233,8 +241,8 @@ export function Editor({
|
|
|
233
241
|
onSave={editor.saveNow}
|
|
234
242
|
onTogglePreview={togglePreview}
|
|
235
243
|
onToggleWordWrap={() => setWordWrap(!wordWrap)}
|
|
236
|
-
onZoomIn={() => setZoomLevel(z => Math.min(
|
|
237
|
-
onZoomOut={() => setZoomLevel(z => Math.max(
|
|
244
|
+
onZoomIn={() => setZoomLevel(z => Math.min(EDITOR_MAX_FONT_SIZE, z + 1))}
|
|
245
|
+
onZoomOut={() => setZoomLevel(z => Math.max(EDITOR_MIN_FONT_SIZE, z - 1))}
|
|
238
246
|
onCopy={editor.copy}
|
|
239
247
|
onDownload={editor.download}
|
|
240
248
|
onToggleMaximize={() => setIsMaximized(!isMaximized)}
|
|
@@ -295,8 +303,8 @@ export function Editor({
|
|
|
295
303
|
fontSize: zoomLevel,
|
|
296
304
|
paddingTop: 16,
|
|
297
305
|
paddingBottom: 16,
|
|
298
|
-
lineHeight:
|
|
299
|
-
fontFamily:
|
|
306
|
+
lineHeight: EDITOR_LINE_HEIGHT,
|
|
307
|
+
fontFamily: editorFontFamily,
|
|
300
308
|
}}
|
|
301
309
|
gutterLineClassName="min-w-[1.5rem]"
|
|
302
310
|
// `min-w-max` keeps a long line intact and lets the panel
|
|
@@ -306,9 +314,9 @@ export function Editor({
|
|
|
306
314
|
editorPadding={16}
|
|
307
315
|
editorClassName="font-mono focus:outline-none"
|
|
308
316
|
editorStyle={{
|
|
309
|
-
fontFamily:
|
|
317
|
+
fontFamily: editorFontFamily,
|
|
310
318
|
fontSize: zoomLevel,
|
|
311
|
-
lineHeight:
|
|
319
|
+
lineHeight: EDITOR_LINE_HEIGHT,
|
|
312
320
|
minHeight: '100%',
|
|
313
321
|
// Breathing room under the last line. It has to live on the
|
|
314
322
|
// surface, not on the scroll container: a scroll container's
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { useMemo, useState } from 'preact/hooks';
|
|
2
2
|
import { ChevronDown, ChevronUp } from 'lucide-preact';
|
|
3
|
-
import { getLanguageFromPath
|
|
3
|
+
import { getLanguageFromPath } from '@/shared/lib/code/language';
|
|
4
|
+
import { highlightLines } from '@/shared/lib/code/syntax-highlight';
|
|
4
5
|
import { useSyntaxReady } from '@/client/hooks/ui/syntax-ready';
|
|
5
6
|
|
|
6
7
|
interface DiffLineItem {
|
|
@@ -0,0 +1,194 @@
|
|
|
1
|
+
import { Check, ClipboardCopy, ClipboardPaste } from 'lucide-preact';
|
|
2
|
+
import { useState } from 'preact/hooks';
|
|
3
|
+
import {
|
|
4
|
+
TERMINAL_KEYS,
|
|
5
|
+
TERMINAL_SYMBOLS,
|
|
6
|
+
type TerminalKeyId,
|
|
7
|
+
type TerminalModifier,
|
|
8
|
+
type TerminalModifiers,
|
|
9
|
+
} from '@/shared/lib/workspace/terminal/keys';
|
|
10
|
+
|
|
11
|
+
interface TerminalKeyBarProps {
|
|
12
|
+
/** Latched modifiers — a tap arms one, and the next key consumes it. */
|
|
13
|
+
modifiers: TerminalModifiers;
|
|
14
|
+
onToggleModifier: (modifier: TerminalModifier) => void;
|
|
15
|
+
onKey: (id: TerminalKeyId) => void;
|
|
16
|
+
/** Cmd+C in a terminal: copy the selection. Resolves false when there was none. */
|
|
17
|
+
onCopy: () => Promise<boolean>;
|
|
18
|
+
/** Cmd+V in a terminal: paste into the shell. Resolves false on a denied clipboard. */
|
|
19
|
+
onPaste: () => Promise<boolean>;
|
|
20
|
+
/** The shell is not running; a key would be sent into nothing. */
|
|
21
|
+
disabled?: boolean;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
const MODIFIERS: readonly { modifier: TerminalModifier; label: string; name: string }[] = [
|
|
25
|
+
{ modifier: 'ctrl', label: 'Ctrl', name: 'Ctrl — applies to the next key' },
|
|
26
|
+
{ modifier: 'alt', label: 'Alt', name: 'Alt / meta — applies to the next key' },
|
|
27
|
+
];
|
|
28
|
+
|
|
29
|
+
const KEY_CLASS =
|
|
30
|
+
'flex items-center justify-center h-8 min-w-8 px-1.5 rounded border font-mono transition-colors ' +
|
|
31
|
+
'cursor-pointer select-none disabled:opacity-40 disabled:cursor-default ' +
|
|
32
|
+
'text-[11px] @[420px]:h-9 @[420px]:min-w-10 @[420px]:text-xs';
|
|
33
|
+
|
|
34
|
+
const IDLE_CLASS = 'bg-canvas text-ink/80 border-ink/15 hover:text-ink hover:bg-ink/5 active:bg-ink/10';
|
|
35
|
+
const ARMED_CLASS = 'bg-ink text-canvas border-ink';
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* The keys a phone's soft keyboard does not have.
|
|
39
|
+
*
|
|
40
|
+
* Every button sends raw bytes into the live PTY, so what the shell sees is
|
|
41
|
+
* identical to a physical keypress (the encoding, and the three rules it obeys,
|
|
42
|
+
* are in `keys.ts`). Ctrl and Alt are LATCHES rather than keys: tapping one arms
|
|
43
|
+
* it, and the next thing you send — a button here or a letter typed on the soft
|
|
44
|
+
* keyboard — carries it, which is the only way a phone reaches Ctrl+C, Ctrl+R
|
|
45
|
+
* or Alt+B.
|
|
46
|
+
*
|
|
47
|
+
* **Copy and Paste stand in for Cmd+C / Cmd+V**, which is what those chords mean
|
|
48
|
+
* in a terminal and the one thing a phone genuinely cannot do: it has no
|
|
49
|
+
* selection to right-click and no paste without a long-press the terminal canvas
|
|
50
|
+
* does not offer. There is no raw Cmd button because xterm discards `metaKey`
|
|
51
|
+
* on every key but Cmd+A (`if (ev.metaKey) break;`, which routes Cmd+A to
|
|
52
|
+
* select-all) — a Cmd button would send nothing, while the Cmd chords the user
|
|
53
|
+
* does have keep working untouched. Copy reports when there was no selection
|
|
54
|
+
* rather than flashing a check for a clipboard it did not fill.
|
|
55
|
+
*
|
|
56
|
+
* `pointerdown` is swallowed so tapping a key never moves focus out of xterm's
|
|
57
|
+
* hidden textarea: blurring it closes the OS keyboard, and a key bar that makes
|
|
58
|
+
* the keyboard disappear is worse than no key bar.
|
|
59
|
+
*
|
|
60
|
+
* Wrapping, not horizontal scrolling. The diff toolbar taught that a
|
|
61
|
+
* `no-scrollbar` overflow hides actions with nothing on screen to say so
|
|
62
|
+
* (measured: 361px of content in a 296px box, the last action entirely
|
|
63
|
+
* outside); a narrow panel here gets three rows of keys instead, and the
|
|
64
|
+
* container query grows them back on a wide one. The container is this bar's
|
|
65
|
+
* own root, so the breakpoints follow the PANEL — 320px on a desktop is the
|
|
66
|
+
* same problem as 320px on a phone.
|
|
67
|
+
*/
|
|
68
|
+
export function TerminalKeyBar({
|
|
69
|
+
modifiers,
|
|
70
|
+
onToggleModifier,
|
|
71
|
+
onKey,
|
|
72
|
+
onCopy,
|
|
73
|
+
onPaste,
|
|
74
|
+
disabled,
|
|
75
|
+
}: TerminalKeyBarProps) {
|
|
76
|
+
const [symbols, setSymbols] = useState(false);
|
|
77
|
+
const [feedback, setFeedback] = useState<'copied' | 'empty' | 'pasteFailed' | null>(null);
|
|
78
|
+
const latched = modifiers.ctrl || modifiers.alt;
|
|
79
|
+
|
|
80
|
+
const flash = (next: typeof feedback) => {
|
|
81
|
+
setFeedback(next);
|
|
82
|
+
setTimeout(() => setFeedback((current) => (current === next ? null : current)), 1500);
|
|
83
|
+
};
|
|
84
|
+
|
|
85
|
+
const handleCopy = async () => {
|
|
86
|
+
flash((await onCopy()) ? 'copied' : 'empty');
|
|
87
|
+
};
|
|
88
|
+
|
|
89
|
+
const handlePaste = async () => {
|
|
90
|
+
if (!(await onPaste())) flash('pasteFailed');
|
|
91
|
+
};
|
|
92
|
+
|
|
93
|
+
return (
|
|
94
|
+
<div
|
|
95
|
+
className="@container flex flex-col gap-1 px-2 py-1.5 border-t border-ink/10 bg-paper flex-shrink-0"
|
|
96
|
+
onPointerDown={(event) => event.preventDefault()}
|
|
97
|
+
>
|
|
98
|
+
{/* Action row: the two latches, the Cmd pair, and the layer switch. */}
|
|
99
|
+
<div className="flex flex-wrap items-center gap-1">
|
|
100
|
+
{MODIFIERS.map(({ modifier, label, name }) => (
|
|
101
|
+
<button
|
|
102
|
+
key={modifier}
|
|
103
|
+
type="button"
|
|
104
|
+
onClick={() => onToggleModifier(modifier)}
|
|
105
|
+
aria-pressed={modifiers[modifier]}
|
|
106
|
+
title={name}
|
|
107
|
+
aria-label={name}
|
|
108
|
+
className={`${KEY_CLASS} ${modifiers[modifier] ? ARMED_CLASS : IDLE_CLASS}`}
|
|
109
|
+
>
|
|
110
|
+
{label}
|
|
111
|
+
</button>
|
|
112
|
+
))}
|
|
113
|
+
|
|
114
|
+
{/* A latch is armed, so say so: an invisible Ctrl turns the next tap into
|
|
115
|
+
a signal the user never asked for. */}
|
|
116
|
+
{latched && (
|
|
117
|
+
<span className="text-[10px] font-mono text-ink/50 px-0.5 whitespace-nowrap" aria-hidden>
|
|
118
|
+
next
|
|
119
|
+
</span>
|
|
120
|
+
)}
|
|
121
|
+
|
|
122
|
+
<span className="w-px h-5 bg-ink/15 flex-shrink-0" aria-hidden />
|
|
123
|
+
|
|
124
|
+
<button
|
|
125
|
+
type="button"
|
|
126
|
+
onClick={handleCopy}
|
|
127
|
+
disabled={disabled}
|
|
128
|
+
title={feedback === 'empty' ? 'Nothing selected' : 'Copy selection'}
|
|
129
|
+
aria-label="Copy selection"
|
|
130
|
+
className={`${KEY_CLASS} ${
|
|
131
|
+
feedback === 'empty'
|
|
132
|
+
? 'bg-warning/10 text-warning border-warning/30'
|
|
133
|
+
: IDLE_CLASS
|
|
134
|
+
}`}
|
|
135
|
+
>
|
|
136
|
+
{feedback === 'copied' ? <Check size={13} /> : <ClipboardCopy size={13} />}
|
|
137
|
+
{/* Labelled, not icon-only: `ClipboardCopy` and `ClipboardPaste` are
|
|
138
|
+
the same 13px clipboard silhouette at a glance, so on a phone the
|
|
139
|
+
pair read as two identical buttons — the one thing a key bar must
|
|
140
|
+
never be. The label is what makes them tell themselves apart. */}
|
|
141
|
+
<span className="pl-1">Copy</span>
|
|
142
|
+
</button>
|
|
143
|
+
|
|
144
|
+
<button
|
|
145
|
+
type="button"
|
|
146
|
+
onClick={handlePaste}
|
|
147
|
+
disabled={disabled}
|
|
148
|
+
title={feedback === 'pasteFailed' ? 'Clipboard unavailable' : 'Paste into the shell'}
|
|
149
|
+
aria-label="Paste into the shell"
|
|
150
|
+
className={`${KEY_CLASS} ${
|
|
151
|
+
feedback === 'pasteFailed'
|
|
152
|
+
? 'bg-error/10 text-error border-error/30'
|
|
153
|
+
: IDLE_CLASS
|
|
154
|
+
}`}
|
|
155
|
+
>
|
|
156
|
+
<ClipboardPaste size={13} />
|
|
157
|
+
<span className="pl-1">Paste</span>
|
|
158
|
+
</button>
|
|
159
|
+
|
|
160
|
+
<button
|
|
161
|
+
type="button"
|
|
162
|
+
onClick={() => setSymbols((previous) => !previous)}
|
|
163
|
+
aria-pressed={symbols}
|
|
164
|
+
title={symbols ? 'Show control keys' : 'Show symbols'}
|
|
165
|
+
aria-label={symbols ? 'Show control keys' : 'Show symbols'}
|
|
166
|
+
className={`${KEY_CLASS} ml-auto ${symbols ? ARMED_CLASS : IDLE_CLASS}`}
|
|
167
|
+
>
|
|
168
|
+
{/* `123` / `ABC`, the labels every phone keyboard already uses for its
|
|
169
|
+
own symbol layer. An icon here was a metaphor the user had to
|
|
170
|
+
decode; the convention needs no decoding. */}
|
|
171
|
+
{symbols ? 'ABC' : '123'}
|
|
172
|
+
</button>
|
|
173
|
+
</div>
|
|
174
|
+
|
|
175
|
+
{/* Key row: the same set swaps in place, the way a phone keyboard's own
|
|
176
|
+
layer key works — two sets stacked would cost the canvas twice. */}
|
|
177
|
+
<div className="flex flex-wrap items-center gap-1">
|
|
178
|
+
{(symbols ? TERMINAL_SYMBOLS : TERMINAL_KEYS).map((def) => (
|
|
179
|
+
<button
|
|
180
|
+
key={def.id}
|
|
181
|
+
type="button"
|
|
182
|
+
disabled={disabled}
|
|
183
|
+
onClick={() => onKey(def.id)}
|
|
184
|
+
title={def.name}
|
|
185
|
+
aria-label={def.name}
|
|
186
|
+
className={`${KEY_CLASS} ${IDLE_CLASS}`}
|
|
187
|
+
>
|
|
188
|
+
{def.label}
|
|
189
|
+
</button>
|
|
190
|
+
))}
|
|
191
|
+
</div>
|
|
192
|
+
</div>
|
|
193
|
+
);
|
|
194
|
+
}
|
|
@@ -17,6 +17,21 @@ export interface RealtimeXtermHandle {
|
|
|
17
17
|
/** Drop the buffer and reset the cursor — used when the shell is replaced. */
|
|
18
18
|
reset: () => void;
|
|
19
19
|
focus: () => void;
|
|
20
|
+
/**
|
|
21
|
+
* The shell's DECCKM state, read at the moment a key bar button is pressed.
|
|
22
|
+
* A full-screen app (vim, less) sets it and then expects the SS3 spelling of
|
|
23
|
+
* the arrows, so the encoding cannot be decided once at mount.
|
|
24
|
+
*/
|
|
25
|
+
applicationCursorKeys: () => boolean;
|
|
26
|
+
/** The current selection, empty when nothing is selected. */
|
|
27
|
+
getSelection: () => string;
|
|
28
|
+
/**
|
|
29
|
+
* Feed text in as a paste. Goes through xterm's own `paste`, which is what
|
|
30
|
+
* wraps it in the bracketed-paste markers when the shell asked for them —
|
|
31
|
+
* writing the bytes directly would make a multi-line paste execute line by
|
|
32
|
+
* line in an editor that expects one block.
|
|
33
|
+
*/
|
|
34
|
+
paste: (text: string) => void;
|
|
20
35
|
}
|
|
21
36
|
|
|
22
37
|
interface RealtimeXtermViewProps {
|
|
@@ -76,6 +91,15 @@ export const RealtimeXtermView = forwardRef<RealtimeXtermHandle, RealtimeXtermVi
|
|
|
76
91
|
focus() {
|
|
77
92
|
mountRef.current?.term.focus();
|
|
78
93
|
},
|
|
94
|
+
applicationCursorKeys() {
|
|
95
|
+
return mountRef.current?.term.modes.applicationCursorKeysMode ?? false;
|
|
96
|
+
},
|
|
97
|
+
getSelection() {
|
|
98
|
+
return mountRef.current?.term.getSelection() ?? '';
|
|
99
|
+
},
|
|
100
|
+
paste(text: string) {
|
|
101
|
+
mountRef.current?.term.paste(text);
|
|
102
|
+
},
|
|
79
103
|
}), []);
|
|
80
104
|
|
|
81
105
|
const scrollToBottom = useCallback(() => {
|
|
@@ -1,10 +1,22 @@
|
|
|
1
|
-
import { useCallback, useRef } from 'preact/hooks';
|
|
1
|
+
import { useCallback, useRef, useState } from 'preact/hooks';
|
|
2
2
|
import { useTerminal } from '@/client/hooks/workspace/terminal';
|
|
3
3
|
import { useRepoList, useRepoScope } from '@/client/hooks/workspace/repo-scope';
|
|
4
4
|
import { useTheme } from '@/client/hooks/ui/theme';
|
|
5
|
+
import { useTouchDevice } from '@/client/hooks/ui/touch-device';
|
|
6
|
+
import { useKeyboardInset } from '@/client/hooks/ui/keyboard-inset';
|
|
7
|
+
import { copyToClipboard, readClipboardText } from '@/client/hooks/ui/clipboard';
|
|
5
8
|
import { TerminalHeader } from '@/client/components/workspace/terminal-panel/Header';
|
|
6
9
|
import { TerminalQuickActions } from '@/client/components/workspace/terminal-panel/QuickActions';
|
|
10
|
+
import { TerminalKeyBar } from '@/client/components/workspace/terminal-panel/KeyBar';
|
|
7
11
|
import { RealtimeXtermView, type RealtimeXtermHandle } from '@/client/components/workspace/terminal-panel/RealtimeXtermView';
|
|
12
|
+
import {
|
|
13
|
+
NO_MODIFIERS,
|
|
14
|
+
applyModifiers,
|
|
15
|
+
resolveKeySequence,
|
|
16
|
+
type TerminalKeyId,
|
|
17
|
+
type TerminalModifier,
|
|
18
|
+
type TerminalModifiers,
|
|
19
|
+
} from '@/shared/lib/workspace/terminal/keys';
|
|
8
20
|
|
|
9
21
|
interface TerminalPanelProps {
|
|
10
22
|
className?: string;
|
|
@@ -18,9 +30,22 @@ const encoder = new TextEncoder();
|
|
|
18
30
|
export function TerminalPanel({ className = '', enabled = true, rootPath, showHeader = true }: TerminalPanelProps) {
|
|
19
31
|
const xtermRef = useRef<RealtimeXtermHandle>(null);
|
|
20
32
|
const { isDark } = useTheme();
|
|
33
|
+
// Only a device without a physical keyboard gets the key bar: on a desktop
|
|
34
|
+
// every one of those keys is already under the user's fingers, and a strip of
|
|
35
|
+
// them would cost the canvas height for nothing.
|
|
36
|
+
const isTouchDevice = useTouchDevice();
|
|
37
|
+
// The inset is measured from the panel's OWN bottom edge, so it can never
|
|
38
|
+
// double-count a keyboard the container already shrank for.
|
|
39
|
+
const rootRef = useRef<HTMLDivElement>(null);
|
|
40
|
+
const keyboardInset = useKeyboardInset(rootRef);
|
|
41
|
+
const [modifiers, setModifiers] = useState<TerminalModifiers>(NO_MODIFIERS);
|
|
21
42
|
const { activeRepo, setActiveRepo } = useRepoScope(rootPath, 'terminal.activeRepo');
|
|
22
43
|
const { repos, scanning: reposScanning, rescan: rescanRepos } = useRepoList(rootPath, enabled);
|
|
23
44
|
const gridRef = useRef<{ cols: number; rows: number } | null>(null);
|
|
45
|
+
// The latch is read inside `handleInput`, which xterm calls outside React's
|
|
46
|
+
// render — a ref is what makes it the value at the keystroke, not at mount.
|
|
47
|
+
const modifiersRef = useRef(modifiers);
|
|
48
|
+
modifiersRef.current = modifiers;
|
|
24
49
|
|
|
25
50
|
const handleOutput = useCallback((bytes: Uint8Array) => {
|
|
26
51
|
xtermRef.current?.write(bytes);
|
|
@@ -70,10 +95,59 @@ export function TerminalPanel({ className = '', enabled = true, rootPath, showHe
|
|
|
70
95
|
|
|
71
96
|
// Keystrokes arrive as text; `onBinary` payloads are latin-1 bytes and must
|
|
72
97
|
// not be UTF-8 encoded on the way out.
|
|
98
|
+
//
|
|
99
|
+
// A latched modifier is folded in here, on the way out, because that is the
|
|
100
|
+
// only place a soft keyboard's letters pass through — Ctrl+C on a phone is a
|
|
101
|
+
// tap on `Ctrl` and then the `c` the OS keyboard produces.
|
|
73
102
|
const handleInput = useCallback((data: string) => {
|
|
74
|
-
|
|
103
|
+
const latched = modifiersRef.current;
|
|
104
|
+
const armed = latched.ctrl || latched.alt;
|
|
105
|
+
const encoded = applyModifiers(data, latched);
|
|
106
|
+
// Only a single typed character consumes the latch. A paste, a mouse report
|
|
107
|
+
// or a focus event arrives on this same event and must leave the armed
|
|
108
|
+
// modifier alone, or the tap that armed it is silently spent on a no-op.
|
|
109
|
+
if (armed && data.length === 1) setModifiers(NO_MODIFIERS);
|
|
110
|
+
input(encoder.encode(encoded));
|
|
75
111
|
}, [input]);
|
|
76
112
|
|
|
113
|
+
const handleToggleModifier = useCallback((modifier: TerminalModifier) => {
|
|
114
|
+
setModifiers((previous) => ({ ...previous, [modifier]: !previous[modifier] }));
|
|
115
|
+
xtermRef.current?.focus();
|
|
116
|
+
}, []);
|
|
117
|
+
|
|
118
|
+
const handleKey = useCallback((id: TerminalKeyId) => {
|
|
119
|
+
const sequence = resolveKeySequence(id, {
|
|
120
|
+
applicationCursorKeys: xtermRef.current?.applicationCursorKeys() ?? false,
|
|
121
|
+
modifiers: modifiersRef.current,
|
|
122
|
+
});
|
|
123
|
+
if (!sequence) return;
|
|
124
|
+
input(encoder.encode(sequence));
|
|
125
|
+
setModifiers(NO_MODIFIERS);
|
|
126
|
+
// Keep the OS keyboard where it was; a key bar tap must not dismiss it.
|
|
127
|
+
xtermRef.current?.focus();
|
|
128
|
+
}, [input]);
|
|
129
|
+
|
|
130
|
+
/**
|
|
131
|
+
* Cmd+C / Cmd+V, the two chords a terminal actually has — a phone can neither
|
|
132
|
+
* select on the canvas nor reach its own clipboard from there. Copy reports
|
|
133
|
+
* whether it had anything to copy so the bar can say "nothing selected"
|
|
134
|
+
* instead of flashing success over an untouched clipboard.
|
|
135
|
+
*/
|
|
136
|
+
const handleCopy = useCallback(async () => {
|
|
137
|
+
const selection = xtermRef.current?.getSelection() ?? '';
|
|
138
|
+
if (!selection) return false;
|
|
139
|
+
return copyToClipboard(selection);
|
|
140
|
+
}, []);
|
|
141
|
+
|
|
142
|
+
const handlePaste = useCallback(async () => {
|
|
143
|
+
const text = await readClipboardText();
|
|
144
|
+
if (text === null || text.length === 0) return false;
|
|
145
|
+
// Through xterm's own paste, so a bracketed-paste shell gets its markers.
|
|
146
|
+
xtermRef.current?.paste(text);
|
|
147
|
+
xtermRef.current?.focus();
|
|
148
|
+
return true;
|
|
149
|
+
}, []);
|
|
150
|
+
|
|
77
151
|
const handleBinaryInput = useCallback((data: string) => {
|
|
78
152
|
const bytes = new Uint8Array(data.length);
|
|
79
153
|
for (let i = 0; i < data.length; i += 1) bytes[i] = data.charCodeAt(i) & 0xff;
|
|
@@ -98,7 +172,17 @@ export function TerminalPanel({ className = '', enabled = true, rootPath, showHe
|
|
|
98
172
|
}
|
|
99
173
|
|
|
100
174
|
return (
|
|
101
|
-
<div
|
|
175
|
+
<div
|
|
176
|
+
ref={rootRef}
|
|
177
|
+
className={`flex flex-col h-full w-full bg-paper text-ink overflow-hidden ${className}`}
|
|
178
|
+
// The soft keyboard covers the bottom of the visual viewport without
|
|
179
|
+
// shrinking the layout one, so the key bar would sit behind the keys the
|
|
180
|
+
// user is typing on. Lifting by the panel's actual overlap with the
|
|
181
|
+
// visible area keeps the bar — and the shell's own grid, which the fit
|
|
182
|
+
// observer re-measures — above the keyboard, and lifts nothing at all
|
|
183
|
+
// when the container already ends above it.
|
|
184
|
+
style={keyboardInset > 0 ? { paddingBottom: keyboardInset } : undefined}
|
|
185
|
+
>
|
|
102
186
|
{showHeader && (
|
|
103
187
|
<TerminalHeader
|
|
104
188
|
status={status}
|
|
@@ -135,6 +219,17 @@ export function TerminalPanel({ className = '', enabled = true, rootPath, showHe
|
|
|
135
219
|
onGridChange={handleGridChange}
|
|
136
220
|
/>
|
|
137
221
|
</div>
|
|
222
|
+
|
|
223
|
+
{isTouchDevice && (
|
|
224
|
+
<TerminalKeyBar
|
|
225
|
+
modifiers={modifiers}
|
|
226
|
+
onToggleModifier={handleToggleModifier}
|
|
227
|
+
onKey={handleKey}
|
|
228
|
+
onCopy={handleCopy}
|
|
229
|
+
onPaste={handlePaste}
|
|
230
|
+
disabled={status !== 'running'}
|
|
231
|
+
/>
|
|
232
|
+
)}
|
|
138
233
|
</div>
|
|
139
234
|
);
|
|
140
235
|
}
|