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.
Files changed (52) hide show
  1. package/dist/client/index.html +1 -1
  2. package/dist/client/index.js +239 -230
  3. package/dist/client/static/js/{index-1q6165r3.js → index-4qx6m2p7.js} +640 -640
  4. package/dist/client/static/js/index-bsxxt89j.css +1 -0
  5. package/package.json +4 -3
  6. package/public/sw.js +54 -5
  7. package/src/client/components/mobile/mobile-right-sidebar/FullEditor.tsx +27 -8
  8. package/src/client/components/mobile/mobile-session-sidebar/Item.tsx +1 -0
  9. package/src/client/components/mobile/mobile-session-sidebar/SessionRow.test.ts +84 -0
  10. package/src/client/components/mobile/mobile-session-sidebar/SessionRow.tsx +14 -5
  11. package/src/client/components/settings/Modal.tsx +10 -17
  12. package/src/client/components/settings/categories/AppearanceSettings.tsx +3 -0
  13. package/src/client/components/settings/categories/appearance-settings/EditorFontSection.tsx +78 -0
  14. package/src/client/components/workspace/chat-timeline/SystemNotice.tsx +17 -11
  15. package/src/client/components/workspace/editor/index.tsx +15 -7
  16. package/src/client/components/workspace/terminal-panel/KeyBar.tsx +194 -0
  17. package/src/client/components/workspace/terminal-panel/RealtimeXtermView.tsx +24 -0
  18. package/src/client/components/workspace/terminal-panel/index.tsx +98 -3
  19. package/src/client/components/workspace/terminal-panel/mount.ts +47 -0
  20. package/src/client/data/theme/terminal.ts +25 -8
  21. package/src/client/hooks/editor/font.ts +53 -0
  22. package/src/client/hooks/ui/clipboard.ts +17 -0
  23. package/src/client/hooks/ui/keyboard-inset.test.ts +46 -0
  24. package/src/client/hooks/ui/keyboard-inset.ts +113 -0
  25. package/src/client/hooks/ui/text-overflow.test.ts +152 -0
  26. package/src/client/hooks/ui/text-overflow.ts +78 -0
  27. package/src/client/hooks/ui/touch-device.ts +46 -0
  28. package/src/client/main.tsx +1 -0
  29. package/src/server/index.ts +4 -3
  30. package/src/server/lib/assets/conditional.server.test.ts +74 -0
  31. package/src/server/lib/assets/conditional.server.ts +54 -0
  32. package/src/server/lib/assets/dev-assets.server.test.ts +44 -0
  33. package/src/server/lib/assets/dev-assets.server.ts +121 -0
  34. package/src/server/lib/assets/fonts.server.ts +9 -4
  35. package/src/server/lib/bundler/build-errors.server.ts +136 -0
  36. package/src/server/lib/bundler/build-errors.test.ts +103 -0
  37. package/src/server/lib/bundler/dependencies.test.ts +148 -0
  38. package/src/server/lib/lifecycle/listener.ts +29 -0
  39. package/src/server/plugins/shell.server.ts +52 -13
  40. package/src/server/plugins/ssr.ts +43 -7
  41. package/src/server/plugins/static.ts +51 -18
  42. package/src/shared/lib/code/editor/typography.test.ts +93 -0
  43. package/src/shared/lib/code/editor/typography.ts +86 -0
  44. package/src/shared/lib/fonts/NOTICE.md +44 -0
  45. package/src/shared/lib/fonts/nerd-symbols.css +55 -0
  46. package/src/shared/lib/fonts/nerd-symbols.test.ts +134 -0
  47. package/src/shared/lib/fonts/symbols-nerd-font-mono.woff2 +0 -0
  48. package/src/shared/lib/settings/diff.test.ts +1 -1
  49. package/src/shared/lib/workspace/terminal/keys.test.ts +189 -0
  50. package/src/shared/lib/workspace/terminal/keys.ts +225 -0
  51. package/src/shared/types/settings/state.ts +7 -16
  52. 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
- {showStatus && status === 'stream' && <Loader2 size={13} className="text-ink/50 animate-spin" />}
87
- {showStatus && (status === 'finish' || status === 'abort') && <Check size={13} className="text-ink/50" />}
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
- fontSize: 'standard',
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 = () =&gt; '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
- const isTruncated = rawTitle.length > 100;
64
- const displayTitle = rawTitle.slice(0, 100) + (isTruncated ? '...' : '');
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
- // If notice has only 1 line of content AND does not exceed the 100-character ellipsis limit,
67
- // disable expand since the entire text is already fully visible in the sub-header.
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 || clean.length > 100 || rawTitle.length > 100;
79
- }, [taskNotice, notice, rawTitle]);
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 && displayTitle && (
173
- <span className="truncate font-mono text-[10.5px] text-ink/45" title={rawTitle}>
174
- {displayTitle}
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', 12);
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(24, z + 1))}
237
- onZoomOut={() => setZoomLevel(z => Math.max(8, z - 1))}
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: 1.5,
299
- fontFamily: '"Fira Code", "JetBrains Mono", "SF Mono", Consolas, monospace',
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: '"Fira Code", "JetBrains Mono", "SF Mono", Consolas, monospace',
317
+ fontFamily: editorFontFamily,
310
318
  fontSize: zoomLevel,
311
- lineHeight: 1.5,
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(() => {