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.
Files changed (151) hide show
  1. package/dist/client/index.html +1 -1
  2. package/dist/client/index.js +306 -298
  3. package/dist/client/static/js/index-bsxxt89j.css +1 -0
  4. package/dist/client/static/js/{index-qhzsybkn.js → index-qkt4z7x0.js} +658 -658
  5. package/package.json +4 -3
  6. package/public/sw.js +54 -5
  7. package/src/cli/lib/output.js +15 -8
  8. package/src/client/components/common/Modal.tsx +14 -4
  9. package/src/client/components/{layout/session-sidebar/SubagentList.tsx → common/subagent-list/index.tsx} +8 -10
  10. package/src/client/components/layout/session-sidebar/CategoryItem.tsx +6 -30
  11. package/src/client/components/layout/session-sidebar/Toolbar.tsx +11 -5
  12. package/src/client/components/layout/session-sidebar/index.tsx +4 -2
  13. package/src/client/components/mobile/LayoutWrapper.tsx +6 -0
  14. package/src/client/components/mobile/SessionSidebar.tsx +4 -0
  15. package/src/client/components/mobile/mobile-right-sidebar/FullEditor.tsx +27 -8
  16. package/src/client/components/mobile/mobile-session-sidebar/Item.tsx +67 -29
  17. package/src/client/components/mobile/mobile-session-sidebar/SessionRow.test.ts +84 -0
  18. package/src/client/components/mobile/mobile-session-sidebar/SessionRow.tsx +59 -12
  19. package/src/client/components/mobile/mobile-session-sidebar/Toolbar.tsx +21 -1
  20. package/src/client/components/settings/Modal.tsx +45 -23
  21. package/src/client/components/settings/SettingsSidebarList.tsx +1 -1
  22. package/src/client/components/settings/categories/AgentSettings.tsx +45 -28
  23. package/src/client/components/settings/categories/AppearanceSettings.tsx +3 -0
  24. package/src/client/components/settings/categories/CommandSettings.tsx +45 -28
  25. package/src/client/components/settings/categories/McpSettings.tsx +48 -31
  26. package/src/client/components/settings/categories/ProjectSettings.tsx +34 -19
  27. package/src/client/components/settings/categories/ProviderSettings.tsx +77 -53
  28. package/src/client/components/settings/categories/SkillSettings.tsx +34 -17
  29. package/src/client/components/settings/categories/UsageSettings.tsx +53 -39
  30. package/src/client/components/settings/categories/appearance-settings/EditorFontSection.tsx +78 -0
  31. package/src/client/components/settings/categories/behavior-settings/Editor.tsx +7 -7
  32. package/src/client/components/settings/categories/mcp-settings/ImportModal.tsx +1 -1
  33. package/src/client/components/settings/categories/mcp-settings/SidebarList.tsx +1 -1
  34. package/src/client/components/settings/categories/project-settings/SidebarList.tsx +1 -1
  35. package/src/client/components/settings/categories/provider-settings/AuthSection.tsx +8 -3
  36. package/src/client/components/settings/categories/provider-settings/DeleteProviderModal.tsx +1 -1
  37. package/src/client/components/settings/categories/provider-settings/ModelsList.tsx +9 -4
  38. package/src/client/components/settings/categories/provider-settings/SidebarList.tsx +1 -1
  39. package/src/client/components/settings/categories/skill-catalog/AddSourceModal.tsx +1 -1
  40. package/src/client/components/settings/categories/skill-settings/SidebarList.tsx +1 -1
  41. package/src/client/components/settings/categories/usage-settings/SidebarList.tsx +1 -1
  42. package/src/client/components/settings/master-detail/index.tsx +68 -0
  43. package/src/client/components/workspace/SearchPanel.tsx +2 -1
  44. package/src/client/components/workspace/chat-timeline/SubagentView.tsx +7 -1
  45. package/src/client/components/workspace/chat-timeline/SystemNotice.tsx +19 -12
  46. package/src/client/components/workspace/chat-timeline/TaskResultContent.tsx +2 -1
  47. package/src/client/components/workspace/chat-timeline/index.tsx +1 -0
  48. package/src/client/components/workspace/chat-timeline/tool-renderers/hashline-patch/index.tsx +2 -1
  49. package/src/client/components/workspace/chat-timeline/tool-renderers/panels/AstEdit.tsx +2 -1
  50. package/src/client/components/workspace/chat-timeline/tool-renderers/panels/Bash.tsx +2 -1
  51. package/src/client/components/workspace/chat-timeline/tool-renderers/panels/Edit.tsx +2 -1
  52. package/src/client/components/workspace/chat-timeline/tool-renderers/panels/Read.tsx +2 -1
  53. package/src/client/components/workspace/chat-timeline/tool-renderers/panels/SearchTool.tsx +2 -1
  54. package/src/client/components/workspace/chat-timeline/tool-renderers/shared/DiffView.tsx +2 -1
  55. package/src/client/components/workspace/diff-panel/SplitView.tsx +31 -5
  56. package/src/client/components/workspace/diff-panel/Toolbar.tsx +77 -6
  57. package/src/client/components/workspace/diff-panel/UnifiedView.tsx +18 -5
  58. package/src/client/components/workspace/diff-panel/index.tsx +36 -7
  59. package/src/client/components/workspace/editor/Toolbar.tsx +11 -4
  60. package/src/client/components/workspace/editor/index.tsx +15 -7
  61. package/src/client/components/workspace/git-panel/commit-modal/CommitDiffViewer.tsx +2 -1
  62. package/src/client/components/workspace/terminal-panel/KeyBar.tsx +194 -0
  63. package/src/client/components/workspace/terminal-panel/RealtimeXtermView.tsx +24 -0
  64. package/src/client/components/workspace/terminal-panel/index.tsx +98 -3
  65. package/src/client/components/workspace/terminal-panel/mount.ts +47 -0
  66. package/src/client/data/theme/terminal.ts +25 -8
  67. package/src/client/hooks/chat/omp/session-list.tsx +31 -1
  68. package/src/client/hooks/chat/omp/session-sidebar-controller.ts +7 -1
  69. package/src/client/hooks/chat/subagent/index.ts +37 -11
  70. package/src/client/hooks/editor/font.ts +53 -0
  71. package/src/client/hooks/editor/use-file-editor.ts +1 -1
  72. package/src/client/hooks/settings/master-detail.ts +33 -0
  73. package/src/client/hooks/ui/clipboard.ts +17 -0
  74. package/src/client/hooks/ui/keyboard-inset.test.ts +46 -0
  75. package/src/client/hooks/ui/keyboard-inset.ts +113 -0
  76. package/src/client/hooks/ui/text-overflow.test.ts +152 -0
  77. package/src/client/hooks/ui/text-overflow.ts +78 -0
  78. package/src/client/hooks/ui/touch-device.ts +46 -0
  79. package/src/client/hooks/workspace/expanded-sessions.ts +62 -0
  80. package/src/client/main.tsx +1 -0
  81. package/src/server/index.ts +4 -3
  82. package/src/server/lib/assets/conditional.server.test.ts +74 -0
  83. package/src/server/lib/assets/conditional.server.ts +54 -0
  84. package/src/server/lib/assets/dev-assets.server.test.ts +44 -0
  85. package/src/server/lib/assets/dev-assets.server.ts +121 -0
  86. package/src/server/lib/assets/fonts.server.ts +9 -4
  87. package/src/server/lib/bundler/build-errors.server.ts +136 -0
  88. package/src/server/lib/bundler/build-errors.test.ts +103 -0
  89. package/src/server/lib/bundler/dependencies.test.ts +134 -0
  90. package/src/server/lib/db/client.ts +5 -1
  91. package/src/server/lib/fs/git-diff.ts +34 -10
  92. package/src/server/lib/fs/git-repos.ts +81 -0
  93. package/src/server/lib/lifecycle/flock.ts +199 -0
  94. package/src/server/lib/lifecycle/identity.ts +5 -1
  95. package/src/server/lib/lifecycle/listener.ts +29 -0
  96. package/src/server/lib/lifecycle/port-guard.ts +18 -3
  97. package/src/server/lib/lifecycle/proc/darwin.ts +52 -17
  98. package/src/server/lib/lifecycle/proc/linux.ts +29 -7
  99. package/src/server/lib/lifecycle/proc/ps.ts +7 -0
  100. package/src/server/lib/lifecycle/proc/types.ts +17 -0
  101. package/src/server/lib/lifecycle/proc/win32.ts +5 -0
  102. package/src/server/lib/observer-ws.ts +150 -0
  103. package/src/server/lib/omp/rpc/constants.ts +9 -0
  104. package/src/server/lib/omp/rpc/subagent-liveness.ts +2 -12
  105. package/src/server/lib/omp/session/file-scan.ts +7 -1
  106. package/src/server/lib/omp/session/files.ts +95 -96
  107. package/src/server/lib/omp/session/locator.ts +18 -8
  108. package/src/server/lib/omp/session/messages.ts +7 -7
  109. package/src/server/lib/omp/session/scan.ts +108 -0
  110. package/src/server/lib/omp/session/stats.ts +50 -50
  111. package/src/server/lib/omp/session/telemetry/scan.ts +89 -7
  112. package/src/server/lib/omp/session/telemetry-raw.ts +24 -8
  113. package/src/server/lib/omp/session/usage/aggregate.ts +170 -55
  114. package/src/server/lib/terminal/runtime.server.ts +3 -0
  115. package/src/server/lib/terminal/session.server.ts +30 -3
  116. package/src/server/plugins/shell.server.ts +52 -13
  117. package/src/server/plugins/ssr.ts +43 -7
  118. package/src/server/plugins/static.ts +51 -18
  119. package/src/server/routes/agent/command.test.ts +69 -0
  120. package/src/server/routes/agent/command.ts +14 -0
  121. package/src/server/routes/agent/ws.test.ts +159 -0
  122. package/src/server/routes/agent/ws.ts +22 -97
  123. package/src/server/routes/btw/ws.ts +22 -95
  124. package/src/server/routes/fs/git.ts +61 -70
  125. package/src/server/routes/omp/session-stats.ts +1 -1
  126. package/src/server/routes/telemetry/tokens.ts +6 -4
  127. package/src/server/routes/terminal/run.ts +4 -8
  128. package/src/server/routes/terminal/ws.ts +24 -2
  129. package/src/shared/lib/chat/omp/agent-events.test.ts +33 -1
  130. package/src/shared/lib/chat/omp/agent-events.ts +10 -0
  131. package/src/shared/lib/code/editor/typography.test.ts +93 -0
  132. package/src/shared/lib/code/editor/typography.ts +86 -0
  133. package/src/shared/lib/code/language.ts +146 -0
  134. package/src/shared/lib/code/syntax-highlight.test.ts +2 -1
  135. package/src/shared/lib/code/syntax-highlight.ts +0 -122
  136. package/src/shared/lib/fonts/NOTICE.md +44 -0
  137. package/src/shared/lib/fonts/nerd-symbols.css +55 -0
  138. package/src/shared/lib/fonts/nerd-symbols.test.ts +134 -0
  139. package/src/shared/lib/fonts/symbols-nerd-font-mono.woff2 +0 -0
  140. package/src/shared/lib/fs/diff-parser.test.ts +5 -5
  141. package/src/shared/lib/fs/diff-parser.ts +67 -42
  142. package/src/shared/lib/omp/subagent/parse.test.ts +88 -0
  143. package/src/shared/lib/omp/subagent/parse.ts +17 -0
  144. package/src/shared/lib/omp/subagent/transcript-client.test.ts +100 -0
  145. package/src/shared/lib/omp/subagent/transcript-client.ts +17 -1
  146. package/src/shared/lib/settings/diff.test.ts +1 -1
  147. package/src/shared/lib/workspace/terminal/keys.test.ts +189 -0
  148. package/src/shared/lib/workspace/terminal/keys.ts +225 -0
  149. package/src/shared/types/omp/subagent.ts +6 -0
  150. package/src/shared/types/settings/state.ts +7 -16
  151. 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="pb-4">
45
- <table className="w-max min-w-full border-collapse">
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="py-0.5 px-3 whitespace-pre text-ink pr-6">
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/syntax-highlight';
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
- const parsed = parseUnifiedDiff(rawDiff, ignoreWhitespace);
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
- <div className="flex items-center justify-between px-3 py-2 border-b border-ink/10 bg-paper flex-shrink-0">
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
- <div className="flex items-center space-x-1 text-ink/40 flex-shrink-0">
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', 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
@@ -1,6 +1,7 @@
1
1
  import { useMemo, useState } from 'preact/hooks';
2
2
  import { ChevronDown, ChevronUp } from 'lucide-preact';
3
- import { getLanguageFromPath, highlightLines } from '@/shared/lib/code/syntax-highlight';
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
- input(encoder.encode(data));
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 className={`flex flex-col h-full w-full bg-paper text-ink overflow-hidden ${className}`}>
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
  }