ompchamber 3.2.0 → 3.3.1
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 +239 -230
- package/dist/client/static/js/{index-1q6165r3.js → index-4qx6m2p7.js} +640 -640
- package/dist/client/static/js/index-bsxxt89j.css +1 -0
- package/package.json +4 -3
- package/public/sw.js +54 -5
- package/src/client/components/mobile/mobile-right-sidebar/FullEditor.tsx +27 -8
- package/src/client/components/mobile/mobile-session-sidebar/Item.tsx +1 -0
- package/src/client/components/mobile/mobile-session-sidebar/SessionRow.test.ts +84 -0
- package/src/client/components/mobile/mobile-session-sidebar/SessionRow.tsx +14 -5
- package/src/client/components/settings/Modal.tsx +10 -17
- package/src/client/components/settings/categories/AppearanceSettings.tsx +3 -0
- package/src/client/components/settings/categories/appearance-settings/EditorFontSection.tsx +78 -0
- package/src/client/components/workspace/chat-timeline/SystemNotice.tsx +17 -11
- package/src/client/components/workspace/editor/index.tsx +15 -7
- 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/editor/font.ts +53 -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/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 +148 -0
- package/src/server/lib/lifecycle/listener.ts +29 -0
- 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/shared/lib/code/editor/typography.test.ts +93 -0
- package/src/shared/lib/code/editor/typography.ts +86 -0
- 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/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/settings/state.ts +7 -16
- package/dist/client/static/js/index-01dv383b.css +0 -1
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { Archive, ArchiveRestore, Check, ChevronDown, ChevronRight, Loader2, Pencil } from 'lucide-preact';
|
|
1
|
+
import { Archive, ArchiveRestore, Check, ChevronDown, ChevronRight, CircleQuestionMark, Loader2, Pencil } from 'lucide-preact';
|
|
2
2
|
import type { SessionItemData } from '@/shared/types';
|
|
3
3
|
import { useInlineRename } from '@/client/hooks/ui/inline-rename';
|
|
4
4
|
|
|
@@ -6,6 +6,8 @@ export interface MobileSessionRowProps {
|
|
|
6
6
|
session: SessionItemData;
|
|
7
7
|
isActive: boolean;
|
|
8
8
|
status?: 'stream' | 'finish' | 'abort';
|
|
9
|
+
/** The session's agent is blocked on a question until the user answers it. */
|
|
10
|
+
awaitingInput?: boolean;
|
|
9
11
|
/** Relative age of the session, or null when no usable timestamp exists. */
|
|
10
12
|
timeAgo: string | null;
|
|
11
13
|
/** The session has a subagent roster (on disk or live), so it can be expanded. */
|
|
@@ -21,6 +23,7 @@ export function MobileSessionRow({
|
|
|
21
23
|
session,
|
|
22
24
|
isActive,
|
|
23
25
|
status,
|
|
26
|
+
awaitingInput = false,
|
|
24
27
|
timeAgo,
|
|
25
28
|
hasSubagents = false,
|
|
26
29
|
isExpanded = false,
|
|
@@ -49,7 +52,6 @@ export function MobileSessionRow({
|
|
|
49
52
|
// pinned whenever the row has a roster; a hidden one would leave the roster
|
|
50
53
|
// unreachable on a phone.
|
|
51
54
|
const showChevron = hasSubagents && Boolean(onToggleExpand);
|
|
52
|
-
const showStatus = Boolean(status);
|
|
53
55
|
const title = session.title.charAt(0).toUpperCase() + session.title.slice(1);
|
|
54
56
|
|
|
55
57
|
if (isEditing) {
|
|
@@ -81,10 +83,17 @@ export function MobileSessionRow({
|
|
|
81
83
|
>
|
|
82
84
|
<div className="flex items-center min-w-0 pr-2">
|
|
83
85
|
{/* Status slot, 16px at the same x as the folder header's icon slot,
|
|
84
|
-
so the left column reads as one line.
|
|
86
|
+
so the left column reads as one line. Waiting on an answer
|
|
87
|
+
outranks the run spinner — the spinner says work is happening,
|
|
88
|
+
the question mark says it is YOUR turn (desktop parity). */}
|
|
85
89
|
<span className="w-4 flex-shrink-0 flex items-center justify-center">
|
|
86
|
-
{
|
|
87
|
-
|
|
90
|
+
{awaitingInput ? (
|
|
91
|
+
<CircleQuestionMark size={13} className="text-ink/70 animate-pulse" />
|
|
92
|
+
) : status === 'stream' ? (
|
|
93
|
+
<Loader2 size={13} className="text-ink/50 animate-spin" />
|
|
94
|
+
) : status ? (
|
|
95
|
+
<Check size={13} className="text-ink/50" />
|
|
96
|
+
) : null}
|
|
88
97
|
</span>
|
|
89
98
|
<span className="text-xs truncate leading-snug ml-2">{title}</span>
|
|
90
99
|
</div>
|
|
@@ -6,6 +6,8 @@ import { diffSettings } from '@/shared/lib/settings/diff';
|
|
|
6
6
|
import { useChamberSettingsWriter } from '@/client/hooks/settings/use-chamber-setting';
|
|
7
7
|
import { applyDocumentTheme } from '@/client/hooks/ui/theme';
|
|
8
8
|
import { DEFAULT_THEME_ID } from '@/shared/lib/theme/catalog';
|
|
9
|
+
import { EDITOR_DEFAULT_FONT_FAMILY } from '@/shared/lib/code/editor/typography';
|
|
10
|
+
import { EDITOR_FONT_CHANGED_EVENT } from '@/client/hooks/editor/font';
|
|
9
11
|
import { useChamberEvent } from '@/client/hooks/ui/window-event';
|
|
10
12
|
import { SETTINGS_CATEGORIES, SettingsSidebar } from '@/client/components/settings/Sidebar';
|
|
11
13
|
import { AppearanceSettings } from '@/client/components/settings/categories/AppearanceSettings';
|
|
@@ -33,28 +35,12 @@ export interface SettingsModalProps {
|
|
|
33
35
|
}
|
|
34
36
|
|
|
35
37
|
const DEFAULT_SETTINGS: SettingsState = {
|
|
36
|
-
binaryPath: '/Users/you/.bun/bin/omp',
|
|
37
|
-
showUpdateNotifications: true,
|
|
38
|
-
agentControlTool: true,
|
|
39
|
-
ompChamberWebTool: true,
|
|
40
38
|
theme: DEFAULT_THEME_ID,
|
|
41
|
-
|
|
42
|
-
editorFont: 'JetBrains Mono',
|
|
43
|
-
streamResponses: true,
|
|
39
|
+
editorFont: EDITOR_DEFAULT_FONT_FAMILY,
|
|
44
40
|
streamTransport: 'websocket',
|
|
45
41
|
autoSessionTitle: true,
|
|
46
|
-
expandedThinking: true,
|
|
47
|
-
detailedToolCalls: false,
|
|
48
|
-
notificationsEnabled: true,
|
|
49
|
-
buildFailureAlert: true,
|
|
50
42
|
soundAlerts: true,
|
|
51
43
|
chatCompletionSound: true,
|
|
52
|
-
defaultWorkspacePath: '~/Projects/ompchamber',
|
|
53
|
-
tunnelEnabled: true,
|
|
54
|
-
tunnelSubdomain: 'omp-dev-preview',
|
|
55
|
-
activeProvider: 'claude',
|
|
56
|
-
autoApproveSafeCmds: true,
|
|
57
|
-
autoPatchErrors: true,
|
|
58
44
|
followUpBehavior: 'queue',
|
|
59
45
|
keybindingSend: 'Enter',
|
|
60
46
|
keybindingNewLine: 'Shift + Enter',
|
|
@@ -110,6 +96,13 @@ export function SettingsModal({
|
|
|
110
96
|
if (typeof document !== 'undefined' && next.theme) {
|
|
111
97
|
applyDocumentTheme(next.theme);
|
|
112
98
|
}
|
|
99
|
+
// The editor font is visual, so an open editor has to hear about the
|
|
100
|
+
// change now — `appSettings` is a boot-time prop and would only show it
|
|
101
|
+
// after a reload. Announced only when the value actually moved, so a
|
|
102
|
+
// re-render of an unrelated setting does not churn every editor's stack.
|
|
103
|
+
if (typeof window !== 'undefined' && next.editorFont !== prev.editorFont) {
|
|
104
|
+
window.dispatchEvent(new CustomEvent(EDITOR_FONT_CHANGED_EVENT, { detail: next.editorFont }));
|
|
105
|
+
}
|
|
113
106
|
// Only the keys this update actually touched are persisted. The modal
|
|
114
107
|
// holds a full `SettingsState` snapshot taken when it opened, so writing
|
|
115
108
|
// the whole object back would also republish every value it read at that
|
|
@@ -2,6 +2,7 @@ import { Palette } from 'lucide-preact';
|
|
|
2
2
|
import type { SettingsState } from '@/shared/types';
|
|
3
3
|
import { resolveThemeId } from '@/shared/lib/theme/catalog';
|
|
4
4
|
import { ThemeGrid } from '@/client/components/settings/categories/appearance-settings/ThemeGrid';
|
|
5
|
+
import { EditorFontSection } from '@/client/components/settings/categories/appearance-settings/EditorFontSection';
|
|
5
6
|
|
|
6
7
|
interface AppearanceSettingsProps {
|
|
7
8
|
settings: SettingsState;
|
|
@@ -22,6 +23,8 @@ export function AppearanceSettings({ settings, onUpdate }: AppearanceSettingsPro
|
|
|
22
23
|
onSelect={(theme) => onUpdate({ theme: resolveThemeId(theme) })}
|
|
23
24
|
/>
|
|
24
25
|
</section>
|
|
26
|
+
|
|
27
|
+
<EditorFontSection settings={settings} onUpdate={onUpdate} />
|
|
25
28
|
</div>
|
|
26
29
|
);
|
|
27
30
|
}
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @license
|
|
3
|
+
* SPDX-License-Identifier: Apache-2.0
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import { Check, Type } from 'lucide-preact';
|
|
7
|
+
import type { SettingsState } from '@/shared/types';
|
|
8
|
+
import { EDITOR_FONT_CHOICES } from '@/shared/lib/code/editor/typography';
|
|
9
|
+
|
|
10
|
+
interface EditorFontSectionProps {
|
|
11
|
+
settings: SettingsState;
|
|
12
|
+
onUpdate: (updater: Partial<SettingsState> | ((prev: SettingsState) => SettingsState)) => void;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* The code editor's typeface.
|
|
17
|
+
*
|
|
18
|
+
* Only Fira Code is bundled, so each row states whether the face ships with the
|
|
19
|
+
* app or has to come from the device. That is the difference between a choice
|
|
20
|
+
* that always works and one that silently falls back — and the fallback is
|
|
21
|
+
* visible in the row's own preview, which is set in the face it names.
|
|
22
|
+
*/
|
|
23
|
+
export function EditorFontSection({ settings, onUpdate }: EditorFontSectionProps) {
|
|
24
|
+
const active = settings.editorFont || EDITOR_FONT_CHOICES[0].id;
|
|
25
|
+
|
|
26
|
+
return (
|
|
27
|
+
<section className="space-y-3">
|
|
28
|
+
<div>
|
|
29
|
+
<h3 className="font-semibold text-sm uppercase tracking-wider text-ink/80 flex items-center gap-2">
|
|
30
|
+
<Type size={16} className="text-ink/60" />
|
|
31
|
+
Editor Font
|
|
32
|
+
</h3>
|
|
33
|
+
<p className="text-[11px] text-ink/60 mt-1">
|
|
34
|
+
Typeface for the code editor and its line numbers, in both the desktop panel and the phone's
|
|
35
|
+
full-screen editor. Only Fira Code ships with the app; the others are used when your device
|
|
36
|
+
has them, and fall back to Fira Code when it does not.
|
|
37
|
+
</p>
|
|
38
|
+
</div>
|
|
39
|
+
|
|
40
|
+
<div className="grid grid-cols-1 sm:grid-cols-2 gap-2">
|
|
41
|
+
{EDITOR_FONT_CHOICES.map((choice) => {
|
|
42
|
+
const selected = active === choice.id;
|
|
43
|
+
return (
|
|
44
|
+
<button
|
|
45
|
+
key={choice.id}
|
|
46
|
+
type="button"
|
|
47
|
+
onClick={() => onUpdate({ editorFont: choice.id })}
|
|
48
|
+
aria-pressed={selected}
|
|
49
|
+
className={`flex items-center justify-between gap-3 rounded-lg border px-3 py-2 text-left transition-colors ${
|
|
50
|
+
selected ? 'border-ink/40 bg-ink/5' : 'border-ink/12 hover:bg-ink/5'
|
|
51
|
+
}`}
|
|
52
|
+
>
|
|
53
|
+
<span className="min-w-0">
|
|
54
|
+
{/* Preview in the face it names: an uninstalled choice shows its
|
|
55
|
+
own fallback here rather than after the user commits to it. */}
|
|
56
|
+
<span
|
|
57
|
+
className="block truncate text-xs text-ink"
|
|
58
|
+
style={{ fontFamily: `"${choice.id}", monospace` }}
|
|
59
|
+
>
|
|
60
|
+
const greet = () => 'hi'
|
|
61
|
+
</span>
|
|
62
|
+
<span className="mt-0.5 flex items-center gap-1.5 text-[10px] text-ink/50">
|
|
63
|
+
<span>{choice.label}</span>
|
|
64
|
+
{choice.bundled && (
|
|
65
|
+
<span className="rounded bg-ink/8 px-1 py-px text-[9px] uppercase tracking-wide">
|
|
66
|
+
Bundled
|
|
67
|
+
</span>
|
|
68
|
+
)}
|
|
69
|
+
</span>
|
|
70
|
+
</span>
|
|
71
|
+
{selected && <Check size={14} className="text-ink flex-shrink-0" />}
|
|
72
|
+
</button>
|
|
73
|
+
);
|
|
74
|
+
})}
|
|
75
|
+
</div>
|
|
76
|
+
</section>
|
|
77
|
+
);
|
|
78
|
+
}
|
|
@@ -1,8 +1,9 @@
|
|
|
1
|
-
import { useMemo, useState } from 'preact/hooks';
|
|
1
|
+
import { useMemo, useRef, useState } from 'preact/hooks';
|
|
2
2
|
import { AlertCircle, Bell, Bot, CheckCircle2, ChevronDown, Clock, Info, Layers } from 'lucide-preact';
|
|
3
3
|
import { isCodeLike } from '@/shared/lib/code/language';
|
|
4
4
|
import { highlightCode } from '@/shared/lib/code/syntax-highlight';
|
|
5
5
|
import { useSyntaxReady } from '@/client/hooks/ui/syntax-ready';
|
|
6
|
+
import { useIsTruncated } from '@/client/hooks/ui/text-overflow';
|
|
6
7
|
import { parseTaskNotice } from '@/shared/lib/chat/task-result-parser';
|
|
7
8
|
import { TaskResultContent } from '@/client/components/workspace/chat-timeline/TaskResultContent';
|
|
8
9
|
import { MarkdownRenderer } from '@/client/components/common/MarkdownRenderer';
|
|
@@ -60,12 +61,17 @@ export function SystemNotice({ notice }: SystemNoticeProps) {
|
|
|
60
61
|
}, [notice]);
|
|
61
62
|
|
|
62
63
|
const rawTitle = taskNotice?.intro || reminderInfo?.title || genericInfo.firstLine || notice;
|
|
63
|
-
|
|
64
|
-
|
|
64
|
+
// The subtitle is clipped by CSS at the card's own width, so the decision to
|
|
65
|
+
// offer an expander is a MEASUREMENT, not a character count: 76 characters
|
|
66
|
+
// fit a desktop timeline and are cut on a phone card. A char threshold left
|
|
67
|
+
// those rows showing an ellipsis with nothing to click (the chevron was
|
|
68
|
+
// dropped with the button disabled). Measured on the exact node that carries
|
|
69
|
+
// `truncate`, so it cannot disagree with the ellipsis the user sees.
|
|
70
|
+
const subtitleRef = useRef<HTMLSpanElement>(null);
|
|
71
|
+
const subtitleClipped = useIsTruncated(subtitleRef, rawTitle);
|
|
65
72
|
|
|
66
|
-
//
|
|
67
|
-
//
|
|
68
|
-
// If it exceeds the ellipsis limit (>100 chars) or has multiple lines, keep expand active.
|
|
73
|
+
// Expandable when the subtitle is visually clipped, or when the body holds
|
|
74
|
+
// something the one-line header cannot show (more lines, a task-result card).
|
|
69
75
|
const isExpandable = useMemo(() => {
|
|
70
76
|
if (taskNotice) return true;
|
|
71
77
|
const clean = notice
|
|
@@ -75,8 +81,8 @@ export function SystemNotice({ notice }: SystemNoticeProps) {
|
|
|
75
81
|
.split(/\r?\n/)
|
|
76
82
|
.map((l) => l.trim())
|
|
77
83
|
.filter(Boolean);
|
|
78
|
-
return lines.length > 1 ||
|
|
79
|
-
}, [taskNotice, notice,
|
|
84
|
+
return lines.length > 1 || subtitleClipped;
|
|
85
|
+
}, [taskNotice, notice, subtitleClipped]);
|
|
80
86
|
|
|
81
87
|
const noticeStyle = useMemo(() => {
|
|
82
88
|
if (taskNotice) {
|
|
@@ -169,9 +175,9 @@ export function SystemNotice({ notice }: SystemNoticeProps) {
|
|
|
169
175
|
</span>
|
|
170
176
|
)}
|
|
171
177
|
</span>
|
|
172
|
-
{!isOpen &&
|
|
173
|
-
<span className="truncate font-mono text-[10.5px] text-ink/45" title={rawTitle}>
|
|
174
|
-
{
|
|
178
|
+
{!isOpen && rawTitle && (
|
|
179
|
+
<span ref={subtitleRef} className="truncate font-mono text-[10.5px] text-ink/45" title={rawTitle}>
|
|
180
|
+
{rawTitle}
|
|
175
181
|
</span>
|
|
176
182
|
)}
|
|
177
183
|
</span>
|
|
@@ -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
|
|
@@ -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(() => {
|