zerg-ztc 0.1.11 → 0.1.12

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 (122) hide show
  1. package/bin/ztc-audio-darwin-arm64 +0 -0
  2. package/dist/utils/dictation_native.d.ts.map +1 -1
  3. package/dist/utils/dictation_native.js +43 -23
  4. package/dist/utils/dictation_native.js.map +1 -1
  5. package/package.json +5 -4
  6. package/packages/ztc-dictation/Cargo.toml +0 -43
  7. package/packages/ztc-dictation/README.md +0 -65
  8. package/packages/ztc-dictation/index.d.ts +0 -16
  9. package/packages/ztc-dictation/index.js +0 -74
  10. package/packages/ztc-dictation/package.json +0 -41
  11. package/packages/ztc-dictation/src/main.rs +0 -430
  12. package/src/App.tsx +0 -910
  13. package/src/agent/agent.ts +0 -534
  14. package/src/agent/backends/anthropic.ts +0 -86
  15. package/src/agent/backends/gemini.ts +0 -119
  16. package/src/agent/backends/inception.ts +0 -23
  17. package/src/agent/backends/index.ts +0 -17
  18. package/src/agent/backends/openai.ts +0 -23
  19. package/src/agent/backends/openai_compatible.ts +0 -143
  20. package/src/agent/backends/types.ts +0 -83
  21. package/src/agent/commands/clipboard.ts +0 -77
  22. package/src/agent/commands/config.ts +0 -204
  23. package/src/agent/commands/debug.ts +0 -23
  24. package/src/agent/commands/dictation.ts +0 -11
  25. package/src/agent/commands/emulation.ts +0 -80
  26. package/src/agent/commands/execution.ts +0 -9
  27. package/src/agent/commands/help.ts +0 -20
  28. package/src/agent/commands/history.ts +0 -13
  29. package/src/agent/commands/index.ts +0 -48
  30. package/src/agent/commands/input_mode.ts +0 -22
  31. package/src/agent/commands/keybindings.ts +0 -40
  32. package/src/agent/commands/model.ts +0 -11
  33. package/src/agent/commands/models.ts +0 -116
  34. package/src/agent/commands/permissions.ts +0 -64
  35. package/src/agent/commands/retry.ts +0 -9
  36. package/src/agent/commands/shell.ts +0 -68
  37. package/src/agent/commands/skills.ts +0 -54
  38. package/src/agent/commands/status.ts +0 -19
  39. package/src/agent/commands/types.ts +0 -88
  40. package/src/agent/commands/update.ts +0 -32
  41. package/src/agent/factory.ts +0 -60
  42. package/src/agent/index.ts +0 -20
  43. package/src/agent/runtime/capabilities.ts +0 -7
  44. package/src/agent/runtime/memory.ts +0 -23
  45. package/src/agent/runtime/policy.ts +0 -48
  46. package/src/agent/runtime/session.ts +0 -18
  47. package/src/agent/runtime/tracing.ts +0 -23
  48. package/src/agent/tools/file.ts +0 -178
  49. package/src/agent/tools/index.ts +0 -52
  50. package/src/agent/tools/screenshot.ts +0 -821
  51. package/src/agent/tools/search.ts +0 -138
  52. package/src/agent/tools/shell.ts +0 -69
  53. package/src/agent/tools/skills.ts +0 -28
  54. package/src/agent/tools/types.ts +0 -14
  55. package/src/agent/tools/zerg.ts +0 -50
  56. package/src/cli.tsx +0 -163
  57. package/src/components/ActivityLine.tsx +0 -23
  58. package/src/components/FullScreen.tsx +0 -79
  59. package/src/components/Header.tsx +0 -27
  60. package/src/components/InputArea.tsx +0 -1660
  61. package/src/components/MessageList.tsx +0 -71
  62. package/src/components/SingleMessage.tsx +0 -298
  63. package/src/components/StatusBar.tsx +0 -55
  64. package/src/components/index.tsx +0 -8
  65. package/src/config/types.ts +0 -19
  66. package/src/config.ts +0 -186
  67. package/src/debug/logger.ts +0 -14
  68. package/src/emulation/README.md +0 -24
  69. package/src/emulation/catalog.ts +0 -82
  70. package/src/emulation/trace_style.ts +0 -8
  71. package/src/emulation/types.ts +0 -7
  72. package/src/skills/index.ts +0 -36
  73. package/src/skills/loader.ts +0 -135
  74. package/src/skills/registry.ts +0 -6
  75. package/src/skills/types.ts +0 -10
  76. package/src/types.ts +0 -84
  77. package/src/ui/README.md +0 -44
  78. package/src/ui/core/factory.ts +0 -9
  79. package/src/ui/core/index.ts +0 -4
  80. package/src/ui/core/input.ts +0 -38
  81. package/src/ui/core/input_segments.ts +0 -410
  82. package/src/ui/core/input_state.ts +0 -17
  83. package/src/ui/core/layout_yoga.ts +0 -122
  84. package/src/ui/core/style.ts +0 -38
  85. package/src/ui/core/types.ts +0 -54
  86. package/src/ui/ink/index.tsx +0 -1
  87. package/src/ui/ink/render.tsx +0 -60
  88. package/src/ui/views/activity_line.ts +0 -33
  89. package/src/ui/views/app.ts +0 -111
  90. package/src/ui/views/header.ts +0 -44
  91. package/src/ui/views/input_area.ts +0 -255
  92. package/src/ui/views/message_list.ts +0 -443
  93. package/src/ui/views/status_bar.ts +0 -114
  94. package/src/ui/vue/index.ts +0 -53
  95. package/src/ui/web/frame_render.tsx +0 -148
  96. package/src/ui/web/index.tsx +0 -1
  97. package/src/ui/web/render.tsx +0 -41
  98. package/src/utils/clipboard.ts +0 -39
  99. package/src/utils/clipboard_image.ts +0 -40
  100. package/src/utils/dictation.ts +0 -467
  101. package/src/utils/dictation_native.ts +0 -258
  102. package/src/utils/diff.ts +0 -52
  103. package/src/utils/image_preview.ts +0 -36
  104. package/src/utils/models.ts +0 -98
  105. package/src/utils/path_complete.ts +0 -173
  106. package/src/utils/path_format.ts +0 -99
  107. package/src/utils/shell.ts +0 -72
  108. package/src/utils/spinner_frames.ts +0 -1
  109. package/src/utils/spinner_verbs.ts +0 -23
  110. package/src/utils/table.ts +0 -171
  111. package/src/utils/tool_summary.ts +0 -56
  112. package/src/utils/tool_trace.ts +0 -346
  113. package/src/utils/update.ts +0 -44
  114. package/src/utils/version.ts +0 -15
  115. package/src/web/index.html +0 -352
  116. package/src/web/mirror-favicon.svg +0 -4
  117. package/src/web/mirror.html +0 -641
  118. package/src/web/mirror_hook.ts +0 -25
  119. package/src/web/mirror_server.ts +0 -204
  120. package/tsconfig.json +0 -22
  121. package/vite.config.ts +0 -363
  122. /package/{packages/ztc-dictation/bin → bin}/.gitkeep +0 -0
@@ -1,1660 +0,0 @@
1
- import React, { useReducer, useCallback } from 'react';
2
- import { useInput, useStdout, useApp } from 'ink';
3
- import { InkNode } from '../ui/ink/index.js';
4
- import { buildInputAreaView, wrapInputSegments } from '../ui/views/input_area.js';
5
- import { InputBus, InputKey } from '../ui/core/input.js';
6
- import { InputState, InputSegment } from '../ui/core/input_state.js';
7
- import { debugLog } from '../debug/logger.js';
8
- import chalk from 'chalk';
9
- import { saveClipboardImage } from '../utils/clipboard_image.js';
10
- import { renderImagePreview } from '../utils/image_preview.js';
11
- import { completePath } from '../utils/path_complete.js';
12
- import {
13
- isRecording as isLegacyRecording,
14
- startRecording as startLegacyRecording,
15
- stopRecordingAndTranscribe as stopLegacyRecording,
16
- cancelRecording as cancelLegacyRecording,
17
- isDictationAvailable as isLegacyDictationAvailable
18
- } from '../utils/dictation.js';
19
- import {
20
- isNativeDictationAvailable,
21
- isNativeRecording,
22
- startNativeRecording,
23
- stopNativeRecording,
24
- cancelNativeRecording
25
- } from '../utils/dictation_native.js';
26
-
27
- // Use native dictation if available, otherwise fall back to legacy
28
- const useNative = isNativeDictationAvailable();
29
- const isRecording = () => useNative ? isNativeRecording() : isLegacyRecording();
30
- const isDictationAvailable = () => useNative ? true : isLegacyDictationAvailable();
31
- import {
32
- createEmptyState,
33
- insertText,
34
- insertSegment,
35
- insertBadge,
36
- backspace,
37
- deleteForward,
38
- moveLeft,
39
- moveRight,
40
- moveWordLeft,
41
- moveWordRight,
42
- getPlainText,
43
- serializeSegments,
44
- PASTE_BADGE_THRESHOLD
45
- } from '../ui/core/input_segments.js';
46
-
47
- // Helper to check for Ctrl key combinations
48
- // Handles multiple formats:
49
- // 1. Ink's key.ctrl + letter
50
- // 2. Traditional control characters (\x01 for Ctrl+A, etc.)
51
- // 3. Kitty keyboard protocol: [<keycode>;5u where keycode is ASCII, 5 = Ctrl
52
- function isCtrl(input: string, key: InputKey, letter: string): boolean {
53
- const lowerLetter = letter.toLowerCase();
54
-
55
- // Method 1: Ink's key.ctrl flag
56
- if (key.ctrl && input === lowerLetter) return true;
57
-
58
- // Method 2: Traditional control character (Ctrl+A = \x01, etc.)
59
- const ctrlCode = lowerLetter.charCodeAt(0) - 96; // 'a' -> 1, 'b' -> 2, etc.
60
- if (input === String.fromCharCode(ctrlCode)) return true;
61
-
62
- // Method 3: Kitty keyboard protocol [<keycode>;5u (fallback, main handling is in handleKittyInput)
63
- // keycode is ASCII code of the letter, 5 = Ctrl modifier
64
- const asciiCode = lowerLetter.charCodeAt(0); // 'a' -> 97, 'r' -> 114, etc.
65
- const kittyPattern = `[${asciiCode};5u`;
66
- if (input === kittyPattern || input === `\x1b${kittyPattern}`) return true;
67
-
68
- return false;
69
- }
70
-
71
- interface InputAreaProps {
72
- onSubmit: (text: string) => void;
73
- onCommand?: (command: string, args: string[]) => void;
74
- commands?: Array<{
75
- name: string;
76
- description: string;
77
- usage?: string;
78
- }>;
79
- onStateChange?: (state: InputState) => void;
80
- onToast?: (message: string) => void;
81
- onDictationStateChange?: (state: 'idle' | 'recording' | 'transcribing') => void;
82
- cols?: number;
83
- inputBus?: InputBus;
84
- disabled?: boolean;
85
- placeholder?: string;
86
- historyEnabled?: boolean;
87
- debug?: boolean;
88
- cwd?: string; // Working directory for path tab completion
89
- }
90
-
91
- export type { InputState } from '../ui/core/input_state.js';
92
-
93
- type InputAction =
94
- | { type: 'apply'; state: Partial<InputState> }
95
- | { type: 'submit'; historyEnabled: boolean; historyEntry?: string }
96
- | { type: 'history_nav'; direction: 'up' | 'down'; historyEnabled: boolean };
97
-
98
- const initialState: InputState = createEmptyState();
99
-
100
- function reducer(state: InputState, action: InputAction): InputState {
101
- switch (action.type) {
102
- case 'apply':
103
- return { ...state, ...action.state };
104
- case 'submit': {
105
- const history = action.historyEnabled && action.historyEntry
106
- ? [...state.history.slice(-100), action.historyEntry]
107
- : state.history;
108
- return {
109
- ...state,
110
- segments: [],
111
- cursor: { index: 0, offset: 0 },
112
- historyIdx: -1,
113
- history
114
- };
115
- }
116
- case 'history_nav': {
117
- if (!action.historyEnabled || state.history.length === 0) return state;
118
-
119
- let newIdx: number;
120
- if (action.direction === 'up') {
121
- newIdx = state.historyIdx === -1
122
- ? state.history.length - 1
123
- : Math.max(0, state.historyIdx - 1);
124
- } else {
125
- newIdx = state.historyIdx === -1 ? -1 : state.historyIdx + 1;
126
- if (newIdx >= state.history.length) newIdx = -1;
127
- }
128
-
129
- if (newIdx === -1) {
130
- return { ...state, historyIdx: -1, segments: [], cursor: { index: 0, offset: 0 } };
131
- }
132
-
133
- const historyValue = state.history[newIdx];
134
- const nextSegments: InputSegment[] = historyValue.length > 0
135
- ? [{ type: 'text', text: historyValue }]
136
- : [];
137
- return {
138
- ...state,
139
- historyIdx: newIdx,
140
- segments: nextSegments,
141
- cursor: { index: nextSegments.length ? 0 : 0, offset: historyValue.length }
142
- };
143
- }
144
- default:
145
- return state;
146
- }
147
- }
148
-
149
- export const InputArea: React.FC<InputAreaProps> = ({
150
- onSubmit,
151
- onCommand,
152
- commands = [],
153
- onStateChange,
154
- onToast,
155
- onDictationStateChange,
156
- cols = process.stdout.columns || 80,
157
- inputBus,
158
- disabled = false,
159
- placeholder = 'Type a message...',
160
- historyEnabled = true,
161
- debug = false,
162
- cwd = process.cwd()
163
- }) => {
164
- const [state, dispatch] = useReducer(reducer, initialState);
165
- const stateRef = React.useRef(state);
166
- const [badgePreview, setBadgePreview] = React.useState<string[] | null>(null);
167
- const killRingRef = React.useRef<string[]>([]);
168
- const killIndexRef = React.useRef<number>(-1);
169
- const dictationBusyRef = React.useRef<boolean>(false);
170
-
171
- // Bracketed paste mode support - buffer paste content between \x1b[200~ and \x1b[201~
172
- const pasteBufferRef = React.useRef<string>('');
173
- const isPastingRef = React.useRef<boolean>(false);
174
- // Ref for handleClipboardImagePaste to avoid circular dependency
175
- const handleClipboardImagePasteRef = React.useRef<((target: 'state' | 'overlay') => Promise<void>) | null>(null);
176
- React.useEffect(() => {
177
- stateRef.current = state;
178
- onStateChange?.(state);
179
- }, [onStateChange, state]);
180
-
181
- React.useEffect(() => {
182
- const segment = state.segments[state.cursor.index];
183
- if (!segment || segment.type === 'text') {
184
- setBadgePreview(null);
185
- return;
186
- }
187
- if (segment.type === 'paste') {
188
- const lines = segment.text.split('\n');
189
- const preview = lines.slice(0, 3);
190
- if (lines.length > 3) preview.push('…');
191
- setBadgePreview(preview);
192
- return;
193
- }
194
- if (segment.type === 'file') {
195
- setBadgePreview([`[file] ${segment.path}`]);
196
- return;
197
- }
198
- if (segment.type === 'image') {
199
- renderImagePreview(segment.path, 40, 16).then(result => {
200
- if (result && result.length > 0) {
201
- setBadgePreview(result);
202
- } else {
203
- setBadgePreview([`[image] ${segment.path}`, 'Install chafa for preview.']);
204
- }
205
- }).catch(() => {
206
- setBadgePreview([`[image] ${segment.path}`]);
207
- });
208
- }
209
- }, [state.cursor.index, state.segments]);
210
-
211
- const pushKill = useCallback((text: string) => {
212
- if (!text) return;
213
- const ring = killRingRef.current;
214
- ring.unshift(text);
215
- if (ring.length > 20) ring.pop();
216
- killIndexRef.current = 0;
217
- }, []);
218
-
219
- const yank = useCallback((current: InputState) => {
220
- const ring = killRingRef.current;
221
- if (ring.length === 0) return current;
222
- const next = insertText(current, ring[0]);
223
- return { ...next, historyIdx: -1 };
224
- }, []);
225
-
226
- const yankPop = useCallback((current: InputState) => {
227
- const ring = killRingRef.current;
228
- if (ring.length === 0) return current;
229
- const idx = killIndexRef.current === -1 ? 0 : (killIndexRef.current + 1) % ring.length;
230
- killIndexRef.current = idx;
231
- const next = insertText(current, ring[idx]);
232
- return { ...next, historyIdx: -1 };
233
- }, []);
234
-
235
- const killToStart = useCallback((current: InputState): { next: InputState; killed: string } => {
236
- const segments = [...current.segments];
237
- const cursor = current.cursor;
238
- const seg = segments[cursor.index];
239
- if (!seg || seg.type !== 'text') return { next: current, killed: '' };
240
- const killed = seg.text.slice(0, cursor.offset);
241
- seg.text = seg.text.slice(cursor.offset);
242
- return {
243
- next: { ...current, segments, cursor: { index: cursor.index, offset: 0 }, historyIdx: -1 },
244
- killed
245
- };
246
- }, []);
247
-
248
- const killToEnd = useCallback((current: InputState): { next: InputState; killed: string } => {
249
- const segments = [...current.segments];
250
- const cursor = current.cursor;
251
- const seg = segments[cursor.index];
252
- if (!seg || seg.type !== 'text') return { next: current, killed: '' };
253
- const killed = seg.text.slice(cursor.offset);
254
- seg.text = seg.text.slice(0, cursor.offset);
255
- return {
256
- next: { ...current, segments, cursor: { index: cursor.index, offset: seg.text.length }, historyIdx: -1 },
257
- killed
258
- };
259
- }, []);
260
-
261
- const killWordBackward = useCallback((current: InputState): { next: InputState; killed: string } => {
262
- const segments = [...current.segments];
263
- const cursor = current.cursor;
264
- const seg = segments[cursor.index];
265
- if (!seg || seg.type !== 'text') return { next: current, killed: '' };
266
- const before = seg.text.slice(0, cursor.offset);
267
- const after = seg.text.slice(cursor.offset);
268
- const trimmed = before.replace(/\s+$/, '');
269
- const match = trimmed.match(/(\S+)\s*$/);
270
- const killStart = match ? trimmed.length - match[1].length : before.length;
271
- const killed = before.slice(killStart);
272
- seg.text = before.slice(0, killStart) + after;
273
- return {
274
- next: { ...current, segments, cursor: { index: cursor.index, offset: killStart }, historyIdx: -1 },
275
- killed
276
- };
277
- }, []);
278
-
279
- const killWordForward = useCallback((current: InputState): { next: InputState; killed: string } => {
280
- const segments = [...current.segments];
281
- const cursor = current.cursor;
282
- const seg = segments[cursor.index];
283
- if (!seg || seg.type !== 'text') return { next: current, killed: '' };
284
- const before = seg.text.slice(0, cursor.offset);
285
- const after = seg.text.slice(cursor.offset);
286
- const match = after.match(/^(\s*\S+)/);
287
- const killed = match ? match[1] : '';
288
- seg.text = before + after.slice(killed.length);
289
- return {
290
- next: { ...current, segments, cursor, historyIdx: -1 },
291
- killed
292
- };
293
- }, []);
294
-
295
- const transposeChars = useCallback((current: InputState): InputState => {
296
- const segments = [...current.segments];
297
- const cursor = current.cursor;
298
- const seg = segments[cursor.index];
299
- if (!seg || seg.type !== 'text') return current;
300
- const text = seg.text;
301
- if (text.length < 2) return current;
302
- const idx = cursor.offset === 0 ? 0 : cursor.offset === text.length ? text.length - 2 : cursor.offset - 1;
303
- const chars = text.split('');
304
- const a = chars[idx];
305
- chars[idx] = chars[idx + 1];
306
- chars[idx + 1] = a;
307
- seg.text = chars.join('');
308
- return { ...current, segments, cursor: { index: cursor.index, offset: Math.min(text.length, cursor.offset + 1) }, historyIdx: -1 };
309
- }, []);
310
-
311
- const transposeWords = useCallback((current: InputState): InputState => {
312
- const segments = [...current.segments];
313
- const cursor = current.cursor;
314
- const seg = segments[cursor.index];
315
- if (!seg || seg.type !== 'text') return current;
316
- const text = seg.text;
317
- const left = text.slice(0, cursor.offset);
318
- const right = text.slice(cursor.offset);
319
- const leftMatch = left.match(/(\S+)\s*$/);
320
- const rightMatch = right.match(/^\s*(\S+)/);
321
- if (!leftMatch || !rightMatch) return current;
322
- const leftWord = leftMatch[1];
323
- const rightWord = rightMatch[1];
324
- const leftStart = left.length - leftMatch[0].length;
325
- const rightEnd = rightMatch[0].length;
326
- const replacedLeft = left.slice(0, leftStart) + rightWord;
327
- seg.text = replacedLeft + right.slice(rightEnd);
328
- return { ...current, segments, cursor: { index: cursor.index, offset: replacedLeft.length }, historyIdx: -1 };
329
- }, []);
330
-
331
- // Tab completion state
332
- const tabCompletionAlternativesRef = React.useRef<string[]>([]);
333
- const lastTabPrefixRef = React.useRef<string>('');
334
-
335
- // Handle tab completion for shell commands
336
- const handleTabComplete = useCallback(async (current: InputState): Promise<InputState> => {
337
- const plainText = getPlainText(current.segments);
338
-
339
- // Only complete if this looks like a shell command with a path
340
- // Match: !cd path, !ls path, !cat path, etc.
341
- const shellMatch = plainText.match(/^!(\w+)\s+(.*)$/);
342
- if (!shellMatch) {
343
- // Also support just !cd without a path yet
344
- if (plainText.match(/^!cd\s*$/)) {
345
- // Show home directory
346
- const result = await completePath('~/', cwd);
347
- if (result && result.alternatives.length > 0) {
348
- tabCompletionAlternativesRef.current = result.alternatives;
349
- onToast?.(`Completions: ${result.alternatives.slice(0, 5).join(', ')}${result.alternatives.length > 5 ? '...' : ''}`);
350
- }
351
- }
352
- return current;
353
- }
354
-
355
- const partialPath = shellMatch[2];
356
- const commandPrefix = `!${shellMatch[1]} `;
357
-
358
- // Try to complete the path
359
- const result = await completePath(partialPath, cwd);
360
- if (!result) {
361
- onToast?.('No completions');
362
- return current;
363
- }
364
-
365
- // If we have alternatives and this is a repeat tab, show them
366
- if (result.alternatives.length > 1) {
367
- tabCompletionAlternativesRef.current = result.alternatives;
368
- lastTabPrefixRef.current = partialPath;
369
- onToast?.(`Completions: ${result.alternatives.slice(0, 6).join(' ')}${result.alternatives.length > 6 ? ' ...' : ''}`);
370
- } else {
371
- tabCompletionAlternativesRef.current = [];
372
- }
373
-
374
- // Update the input with the completed path
375
- const newText = commandPrefix + result.completed;
376
- const newSegments: InputSegment[] = [{ type: 'text', text: newText }];
377
- return {
378
- ...current,
379
- segments: newSegments,
380
- cursor: { index: 0, offset: newText.length },
381
- historyIdx: -1
382
- };
383
- }, [cwd, onToast]);
384
-
385
- const inputRenderCount = React.useRef(0);
386
- React.useEffect(() => {
387
- inputRenderCount.current += 1;
388
- const totalLength = state.segments.reduce((acc, segment) => (
389
- segment.type === 'text' ? acc + segment.text.length : acc + 0
390
- ), 0);
391
- debugLog(`InputArea render #${inputRenderCount.current} (valueLen=${totalLength})`);
392
- });
393
-
394
- const handleSubmit = useCallback(() => {
395
- const serialized = serializeSegments(state.segments).trim();
396
- if (!serialized) return;
397
-
398
- const plainText = getPlainText(state.segments).trim();
399
- const isCommand = state.segments.length === 1 && state.segments[0].type === 'text' && plainText.startsWith('/');
400
- if (isCommand) {
401
- const parts = plainText.slice(1).split(/\s+/);
402
- const cmd = parts[0].toLowerCase();
403
- const args = parts.slice(1);
404
-
405
- // Handle /pi (paste image) command directly - intercept before passing to onCommand
406
- if (cmd === 'pi' || cmd === 'paste-image') {
407
- dispatch({ type: 'submit', historyEnabled, historyEntry: serialized });
408
- if (handleClipboardImagePasteRef.current) {
409
- void handleClipboardImagePasteRef.current('state');
410
- }
411
- return;
412
- }
413
-
414
- onCommand?.(cmd, args);
415
- } else {
416
- onSubmit(serialized);
417
- }
418
-
419
- dispatch({ type: 'submit', historyEnabled, historyEntry: serialized });
420
- }, [state.segments, onSubmit, onCommand, historyEnabled]);
421
-
422
- const navigateHistory = useCallback((direction: 'up' | 'down') => {
423
- dispatch({ type: 'history_nav', direction, historyEnabled });
424
- }, [historyEnabled]);
425
-
426
- const { stdout } = useStdout();
427
- const { exit } = useApp();
428
- const overlayEnabled = process.env.ZTC_INPUT_OVERLAY === '1' && process.env.ZTC_WEB_MIRROR !== '1';
429
- const overlayStateRef = React.useRef<InputState>(initialState);
430
- const overlayBusyRef = React.useRef(false);
431
- const pasteBusyRef = React.useRef(false);
432
-
433
- const insertTextIntoState = useCallback((text: string) => {
434
- const current = stateRef.current;
435
- const next = insertText(current, text);
436
- dispatch({ type: 'apply', state: { ...next, historyIdx: -1 } });
437
- }, []);
438
-
439
- const renderOverlay = useCallback((overlayState: InputState) => {
440
- if (!stdout || !stdout.isTTY) return;
441
- if (overlayBusyRef.current) return;
442
- overlayBusyRef.current = true;
443
-
444
- const rows = stdout.rows || 24;
445
- const statusHeight = 1;
446
- const cols = stdout.columns || 80;
447
- const wrapped = wrapInputSegments(overlayState.segments, overlayState.cursor, cols);
448
- const inputLines = Math.max(1, wrapped.lines.length);
449
- const inputHeight = inputLines + 4;
450
- const startRow = Math.max(1, rows - (inputHeight + statusHeight) + 1);
451
-
452
- const prompt = disabled ? chalk.gray('❯ ') : chalk.blue('❯ ');
453
-
454
- const lines: string[] = [];
455
- if (disabled) {
456
- lines.push(prompt + chalk.gray(placeholder));
457
- } else if (overlayState.segments.length === 0) {
458
- lines.push(prompt + chalk.inverse('|') + chalk.gray(placeholder));
459
- } else {
460
- wrapped.lines.forEach((lineTokens, index) => {
461
- const prefix = index === 0 ? prompt : ' ';
462
- let lineText = prefix;
463
- lineTokens.forEach((token, tokenIndex) => {
464
- const isCursor = index === wrapped.cursorLine && tokenIndex === wrapped.cursorCol;
465
- if (isCursor) {
466
- lineText += chalk.inverse(token.text || ' ');
467
- } else if (token.style?.color === 'yellow') {
468
- lineText += chalk.gray(token.text);
469
- } else {
470
- lineText += token.text;
471
- }
472
- });
473
- if (index === wrapped.cursorLine && wrapped.cursorCol === lineTokens.length) {
474
- lineText += chalk.inverse(' ');
475
- }
476
- lines.push(lineText);
477
- });
478
- }
479
-
480
- const plainText = overlayState.segments.length === 1 && overlayState.segments[0].type === 'text'
481
- ? overlayState.segments[0].text
482
- : '';
483
- const isCommandMode = plainText.startsWith('/');
484
- const commandQuery = isCommandMode ? plainText.slice(1).trim() : '';
485
- const commandMatches = isCommandMode
486
- ? commands.filter(c => c.name.startsWith(commandQuery)).slice(0, 4)
487
- : [];
488
-
489
- for (let i = 0; i < 4; i += 1) {
490
- const cmd = commandMatches[i];
491
- if (!cmd) {
492
- lines.push(' ');
493
- continue;
494
- }
495
- const usage = cmd.usage ? ` ${cmd.usage}` : '';
496
- const line = `${chalk.cyan.bold(`/${cmd.name}`)}${chalk.white(usage)}${chalk.gray(` — ${cmd.description}`)}`;
497
- lines.push(line);
498
- }
499
-
500
- stdout.write('\x1b[s');
501
- for (let i = 0; i < inputHeight; i += 1) {
502
- const row = startRow + i;
503
- const raw = lines[i] || '';
504
- const trimmed = raw.length > cols ? raw.slice(0, cols) : raw;
505
- stdout.write(`\x1b[${row};1H\x1b[2K${trimmed}`);
506
- }
507
- stdout.write('\x1b[u');
508
- overlayBusyRef.current = false;
509
- }, [commands, disabled, placeholder, stdout]);
510
-
511
- const insertTextIntoOverlay = useCallback((text: string) => {
512
- const current = overlayStateRef.current;
513
- const next = insertText(current, text);
514
- overlayStateRef.current = { ...next, historyIdx: -1 };
515
- renderOverlay(overlayStateRef.current);
516
- }, [renderOverlay]);
517
-
518
- const handleClipboardImagePaste = useCallback(async (target: 'state' | 'overlay') => {
519
- if (pasteBusyRef.current) return;
520
- pasteBusyRef.current = true;
521
- try {
522
- const path = await saveClipboardImage();
523
- if (!path) return;
524
- onToast?.(`Image saved to ${path}`);
525
- if (target === 'overlay') {
526
- const current = overlayStateRef.current;
527
- const next = insertBadge(current, { type: 'image', path });
528
- overlayStateRef.current = { ...next, historyIdx: -1 };
529
- renderOverlay(overlayStateRef.current);
530
- } else {
531
- const current = stateRef.current;
532
- const next = insertBadge(current, { type: 'image', path });
533
- dispatch({ type: 'apply', state: { ...next, historyIdx: -1 } });
534
- }
535
- } finally {
536
- pasteBusyRef.current = false;
537
- }
538
- }, [insertTextIntoOverlay, insertTextIntoState, onToast, renderOverlay]);
539
-
540
- // Update ref so handleSubmit can access it
541
- handleClipboardImagePasteRef.current = handleClipboardImagePaste;
542
-
543
- // Handle Kitty keyboard protocol sequences directly
544
- // This is called when we detect a Kitty sequence in handleInput
545
- const handleKittyInput = useCallback((char: string, key: InputKey, keycode: number, modifier: number) => {
546
- if (disabled) return;
547
-
548
- const isKittyCtrl = modifier === 5;
549
- const isKittyMeta = modifier === 9;
550
- const lowerChar = char.toLowerCase();
551
-
552
- // Navigation
553
- if (isKittyCtrl && lowerChar === 'a') {
554
- dispatch({ type: 'apply', state: { cursor: { index: 0, offset: 0 } } });
555
- return;
556
- }
557
- if (isKittyCtrl && lowerChar === 'e') {
558
- dispatch({ type: 'apply', state: { cursor: { index: state.segments.length, offset: 0 } } });
559
- return;
560
- }
561
- if (isKittyCtrl && lowerChar === 'b') {
562
- dispatch({ type: 'apply', state: moveLeft(state) });
563
- return;
564
- }
565
- if (isKittyCtrl && lowerChar === 'f') {
566
- dispatch({ type: 'apply', state: moveRight(state) });
567
- return;
568
- }
569
- if (isKittyCtrl && lowerChar === 'p') {
570
- navigateHistory('up');
571
- return;
572
- }
573
- if (isKittyCtrl && lowerChar === 'n') {
574
- navigateHistory('down');
575
- return;
576
- }
577
-
578
- // Kill/yank
579
- if (isKittyCtrl && lowerChar === 'u') {
580
- const { next, killed } = killToStart(state);
581
- pushKill(killed);
582
- dispatch({ type: 'apply', state: next });
583
- return;
584
- }
585
- if (isKittyCtrl && lowerChar === 'k') {
586
- const { next, killed } = killToEnd(state);
587
- pushKill(killed);
588
- dispatch({ type: 'apply', state: next });
589
- return;
590
- }
591
- if (isKittyCtrl && lowerChar === 'y') {
592
- dispatch({ type: 'apply', state: yank(state) });
593
- return;
594
- }
595
- if (isKittyCtrl && lowerChar === 'w') {
596
- const { next, killed } = killWordBackward(state);
597
- pushKill(killed);
598
- dispatch({ type: 'apply', state: next });
599
- return;
600
- }
601
-
602
- // Transpose
603
- if (isKittyCtrl && lowerChar === 't') {
604
- dispatch({ type: 'apply', state: transposeChars(state) });
605
- return;
606
- }
607
-
608
- // Delete forward
609
- if (isKittyCtrl && lowerChar === 'd') {
610
- dispatch({ type: 'apply', state: deleteForward(state) });
611
- return;
612
- }
613
-
614
- // Push-to-talk: Ctrl+R to toggle recording
615
- if (isKittyCtrl && lowerChar === 'r') {
616
- if (dictationBusyRef.current) return;
617
-
618
- if (isRecording()) {
619
- // Stop recording and transcribe
620
- dictationBusyRef.current = true;
621
- onDictationStateChange?.('transcribing');
622
-
623
- if (useNative) {
624
- stopNativeRecording()
625
- .then((text) => {
626
- if (text && text.trim()) {
627
- // Submit the transcribed text directly
628
- onSubmit(text.trim());
629
- } else {
630
- onToast?.('No speech detected');
631
- }
632
- })
633
- .catch((err) => {
634
- onToast?.(`Dictation error: ${err.message}`);
635
- })
636
- .finally(() => {
637
- dictationBusyRef.current = false;
638
- onDictationStateChange?.('idle');
639
- });
640
- } else {
641
- stopLegacyRecording()
642
- .then((result) => {
643
- if (result.text && result.text.trim()) {
644
- // Submit the transcribed text directly
645
- onSubmit(result.text.trim());
646
- } else {
647
- onToast?.('No speech detected');
648
- }
649
- })
650
- .catch((err) => {
651
- onToast?.(`Dictation error: ${err.message}`);
652
- })
653
- .finally(() => {
654
- dictationBusyRef.current = false;
655
- onDictationStateChange?.('idle');
656
- });
657
- }
658
- } else {
659
- // Start recording
660
- if (!isDictationAvailable()) {
661
- onToast?.('Dictation not available. Build native/ztc-audio or install sox');
662
- return;
663
- }
664
- try {
665
- if (useNative) {
666
- startNativeRecording({ model: 'tiny' });
667
- } else {
668
- startLegacyRecording();
669
- }
670
- onDictationStateChange?.('recording');
671
- } catch (err) {
672
- onToast?.(`Recording error: ${err instanceof Error ? err.message : 'Unknown'}`);
673
- }
674
- }
675
- return;
676
- }
677
-
678
- // Meta key combinations
679
- if (isKittyMeta && lowerChar === 'y') {
680
- dispatch({ type: 'apply', state: yankPop(state) });
681
- return;
682
- }
683
- if (isKittyMeta && lowerChar === 't') {
684
- dispatch({ type: 'apply', state: transposeWords(state) });
685
- return;
686
- }
687
- if (isKittyMeta && lowerChar === 'd') {
688
- const { next, killed } = killWordForward(state);
689
- pushKill(killed);
690
- dispatch({ type: 'apply', state: next });
691
- return;
692
- }
693
- if (isKittyMeta && lowerChar === 'b') {
694
- dispatch({ type: 'apply', state: moveWordLeft(state) });
695
- return;
696
- }
697
- if (isKittyMeta && lowerChar === 'f') {
698
- dispatch({ type: 'apply', state: moveWordRight(state) });
699
- return;
700
- }
701
-
702
- // Unknown Kitty sequence - don't insert as text
703
- }, [disabled, killToEnd, killToStart, killWordBackward, killWordForward, navigateHistory, onDictationStateChange, onToast, pushKill, state, transposeChars, transposeWords, yank, yankPop]);
704
-
705
- const handleInput = useCallback((input: string, key: InputKey) => {
706
- // Detect Kitty keyboard protocol CSI u sequences
707
- // Format: ESC [ <keycode> ; <modifiers> u
708
- // Modifiers: 2=shift, 3=alt, 5=ctrl, 9=super/cmd
709
- // Check for sequences with or without ESC (Ink may strip it)
710
-
711
- // Ctrl+V or Cmd+V for image paste: [118;5u or [118;9u
712
- const kittyPasteV = /\x1b?\[118;[59]u/.test(input);
713
- if (kittyPasteV) {
714
- void handleClipboardImagePaste('state');
715
- return;
716
- }
717
-
718
- // Handle Kitty keyboard protocol sequences
719
- // Format: ESC? [ keycode ; modifier u
720
- // Modifier 5 = Ctrl, modifier 9 = Cmd/Super
721
- const kittyMatch = input.match(/\x1b?\[(\d+);(\d+)u/);
722
- if (kittyMatch) {
723
- const keycode = parseInt(kittyMatch[1], 10);
724
- const modifier = parseInt(kittyMatch[2], 10);
725
- const isKittyCtrl = modifier === 5;
726
- const isKittyMeta = modifier === 9;
727
-
728
- // Ctrl+C (99;5) or Cmd+C (99;9) - exit the app
729
- if (keycode === 99 && (isKittyCtrl || isKittyMeta)) {
730
- exit();
731
- return;
732
- }
733
-
734
- // Synthesize key flags for Kitty sequences so downstream handlers work
735
- const kittyKey: InputKey = {
736
- ...key,
737
- ctrl: isKittyCtrl || key.ctrl,
738
- meta: isKittyMeta || key.meta,
739
- };
740
- // Convert keycode to character for isCtrl checks
741
- const kittyChar = String.fromCharCode(keycode);
742
-
743
- // Route to handlers based on keycode
744
- // Let the normal handler flow process this with synthesized key flags
745
- // by falling through with modified key/input
746
- handleKittyInput(kittyChar, kittyKey, keycode, modifier);
747
- return;
748
- }
749
-
750
- if (disabled) return;
751
-
752
- // Handle bracketed paste mode markers
753
- // Note: ESC might be stripped or sent separately by Ink, so check both with and without ESC
754
- const PASTE_START_FULL = '\x1b[200~';
755
- const PASTE_START_SHORT = '[200~';
756
- const PASTE_END_FULL = '\x1b[201~';
757
- const PASTE_END_SHORT = '[201~';
758
-
759
- const hasPasteStart = input.includes(PASTE_START_FULL) || input.includes(PASTE_START_SHORT);
760
- const hasPasteEnd = input.includes(PASTE_END_FULL) || input.includes(PASTE_END_SHORT);
761
-
762
- // Check for paste start marker
763
- if (hasPasteStart) {
764
- isPastingRef.current = true;
765
- // Remove the paste start marker (try both variants)
766
- let content = input.replace(PASTE_START_FULL, '').replace(PASTE_START_SHORT, '');
767
-
768
- // Check if paste end is also in this chunk
769
- const hasEndInChunk = content.includes(PASTE_END_FULL) || content.includes(PASTE_END_SHORT);
770
- if (hasEndInChunk) {
771
- let pasteContent = content.replace(PASTE_END_FULL, '').replace(PASTE_END_SHORT, '');
772
- // Normalize line endings: \r\n -> \n, \r -> \n
773
- pasteContent = pasteContent.replace(/\r\n/g, '\n').replace(/\r/g, '\n');
774
- isPastingRef.current = false;
775
- pasteBufferRef.current = '';
776
- // Process the complete paste
777
- if (pasteContent.length > 0) {
778
- const lineCount = pasteContent.split('\n').length;
779
- if (lineCount >= PASTE_BADGE_THRESHOLD) {
780
- const next = insertBadge(state, { type: 'paste', text: pasteContent });
781
- dispatch({ type: 'apply', state: { ...next, historyIdx: -1 } });
782
- } else {
783
- const next = insertText(state, pasteContent);
784
- dispatch({ type: 'apply', state: { ...next, historyIdx: -1 } });
785
- }
786
- } else {
787
- // Empty paste - user may have tried to paste an image
788
- // Try to extract image from system clipboard
789
- void handleClipboardImagePaste('state');
790
- }
791
- } else {
792
- pasteBufferRef.current = content;
793
- }
794
- return;
795
- }
796
-
797
- // Check for paste end marker
798
- if (hasPasteEnd) {
799
- // Remove the paste end marker (try both variants)
800
- const contentBeforeEnd = input.replace(PASTE_END_FULL, '').replace(PASTE_END_SHORT, '');
801
- pasteBufferRef.current += contentBeforeEnd;
802
- // Normalize line endings: \r\n -> \n, \r -> \n
803
- const pasteContent = pasteBufferRef.current.replace(/\r\n/g, '\n').replace(/\r/g, '\n');
804
- isPastingRef.current = false;
805
- pasteBufferRef.current = '';
806
- const lineCount = pasteContent.split('\n').length;
807
- // Process the complete paste
808
- if (pasteContent.length > 0) {
809
- if (lineCount >= PASTE_BADGE_THRESHOLD) {
810
- const next = insertBadge(state, { type: 'paste', text: pasteContent });
811
- dispatch({ type: 'apply', state: { ...next, historyIdx: -1 } });
812
- } else {
813
- const next = insertText(state, pasteContent);
814
- dispatch({ type: 'apply', state: { ...next, historyIdx: -1 } });
815
- }
816
- } else {
817
- // Empty paste - user may have tried to paste an image
818
- // Try to extract image from system clipboard
819
- void handleClipboardImagePaste('state');
820
- }
821
- return;
822
- }
823
-
824
- // If we're in the middle of a paste, buffer the content
825
- if (isPastingRef.current) {
826
- pasteBufferRef.current += input;
827
- return;
828
- }
829
-
830
- // Detect backspace via explicit key flag or known control codes
831
- const backspaceFallback = input === '\b' || input === '\x7f' || input === '\x08';
832
- const isBackspace = key.backspace || key.delete || backspaceFallback;
833
-
834
- // Ignore completely empty input events that aren't backspace
835
- // (some terminals send spurious empty events)
836
- const safeKey = key as InputKey & { tab?: boolean; escape?: boolean };
837
- if (
838
- input === '' &&
839
- !isBackspace &&
840
- !safeKey.leftArrow &&
841
- !safeKey.rightArrow &&
842
- !safeKey.upArrow &&
843
- !safeKey.downArrow &&
844
- !safeKey.return &&
845
- !safeKey.tab &&
846
- !safeKey.escape &&
847
- !safeKey.ctrl &&
848
- !safeKey.meta
849
- ) {
850
- return;
851
- }
852
-
853
- // Try to detect Cmd+V / Ctrl+V for image paste
854
- // Note: Most terminals intercept Cmd+V, so this may not trigger. Use /pi command instead.
855
- if ((key.ctrl || key.meta) && (input === 'v' || input === '\u0016')) {
856
- void handleClipboardImagePaste('state');
857
- return;
858
- }
859
-
860
- if (key.return && !key.shift) {
861
- handleSubmit();
862
- return;
863
- }
864
-
865
- if (key.return && key.shift) {
866
- const next = insertText(state, '\n');
867
- dispatch({ type: 'apply', state: { ...next, historyIdx: -1 } });
868
- return;
869
- }
870
-
871
- if (key.upArrow) {
872
- navigateHistory('up');
873
- return;
874
- }
875
- if (key.downArrow) {
876
- navigateHistory('down');
877
- return;
878
- }
879
-
880
- // Tab completion for shell commands
881
- if (safeKey.tab || input === '\t') {
882
- void handleTabComplete(state).then(nextState => {
883
- if (nextState !== state) {
884
- dispatch({ type: 'apply', state: nextState });
885
- }
886
- });
887
- return;
888
- }
889
-
890
- if (key.leftArrow) {
891
- if (key.ctrl) {
892
- dispatch({ type: 'apply', state: moveWordLeft(state) });
893
- } else {
894
- dispatch({ type: 'apply', state: moveLeft(state) });
895
- }
896
- return;
897
- }
898
- if (key.rightArrow) {
899
- if (key.ctrl) {
900
- dispatch({ type: 'apply', state: moveWordRight(state) });
901
- } else {
902
- dispatch({ type: 'apply', state: moveRight(state) });
903
- }
904
- return;
905
- }
906
-
907
- if (isCtrl(input, key, 'a')) {
908
- dispatch({ type: 'apply', state: { cursor: { index: 0, offset: 0 } } });
909
- return;
910
- }
911
- if (isCtrl(input, key, 'e')) {
912
- dispatch({ type: 'apply', state: { cursor: { index: state.segments.length, offset: 0 } } });
913
- return;
914
- }
915
- if (isCtrl(input, key, 'b')) {
916
- dispatch({ type: 'apply', state: moveLeft(state) });
917
- return;
918
- }
919
- if (isCtrl(input, key, 'f')) {
920
- dispatch({ type: 'apply', state: moveRight(state) });
921
- return;
922
- }
923
- if (isCtrl(input, key, 'p')) {
924
- navigateHistory('up');
925
- return;
926
- }
927
- if (isCtrl(input, key, 'n')) {
928
- navigateHistory('down');
929
- return;
930
- }
931
-
932
- if (isBackspace) {
933
- // On macOS, backspace key reports as key.delete=true, so always use backspace() for backward deletion
934
- const next = backspace(state);
935
- dispatch({ type: 'apply', state: next });
936
- return;
937
- }
938
-
939
- if (isCtrl(input, key, 'u')) {
940
- const { next, killed } = killToStart(state);
941
- pushKill(killed);
942
- dispatch({ type: 'apply', state: next });
943
- return;
944
- }
945
- if (isCtrl(input, key, 'k')) {
946
- const { next, killed } = killToEnd(state);
947
- pushKill(killed);
948
- dispatch({ type: 'apply', state: next });
949
- return;
950
- }
951
- if (isCtrl(input, key, 'y')) {
952
- dispatch({ type: 'apply', state: yank(state) });
953
- return;
954
- }
955
- if (key.meta && input === 'y') {
956
- dispatch({ type: 'apply', state: yankPop(state) });
957
- return;
958
- }
959
- // Push-to-talk: Ctrl+R to toggle recording
960
- if (isCtrl(input, key, 'r')) {
961
- if (dictationBusyRef.current) return;
962
-
963
- if (isRecording()) {
964
- // Stop recording and transcribe
965
- dictationBusyRef.current = true;
966
- onDictationStateChange?.('transcribing');
967
-
968
- if (useNative) {
969
- stopNativeRecording()
970
- .then((text) => {
971
- if (text && text.trim()) {
972
- // Submit the transcribed text directly
973
- onSubmit(text.trim());
974
- } else {
975
- onToast?.('No speech detected');
976
- }
977
- })
978
- .catch((err) => {
979
- onToast?.(`Dictation error: ${err.message}`);
980
- })
981
- .finally(() => {
982
- dictationBusyRef.current = false;
983
- onDictationStateChange?.('idle');
984
- });
985
- } else {
986
- stopLegacyRecording()
987
- .then((result) => {
988
- if (result.text && result.text.trim()) {
989
- // Submit the transcribed text directly
990
- onSubmit(result.text.trim());
991
- } else {
992
- onToast?.('No speech detected');
993
- }
994
- })
995
- .catch((err) => {
996
- onToast?.(`Dictation error: ${err.message}`);
997
- })
998
- .finally(() => {
999
- dictationBusyRef.current = false;
1000
- onDictationStateChange?.('idle');
1001
- });
1002
- }
1003
- } else {
1004
- // Start recording
1005
- if (!isDictationAvailable()) {
1006
- onToast?.('Dictation not available. Build native/ztc-audio or install sox');
1007
- return;
1008
- }
1009
- try {
1010
- if (useNative) {
1011
- startNativeRecording({ model: 'tiny' });
1012
- } else {
1013
- startLegacyRecording();
1014
- }
1015
- onDictationStateChange?.('recording');
1016
- } catch (err) {
1017
- onToast?.(`Recording error: ${err instanceof Error ? err.message : 'Unknown'}`);
1018
- }
1019
- }
1020
- return;
1021
- }
1022
- if (isCtrl(input, key, 't')) {
1023
- dispatch({ type: 'apply', state: transposeChars(state) });
1024
- return;
1025
- }
1026
- if (key.meta && input === 't') {
1027
- dispatch({ type: 'apply', state: transposeWords(state) });
1028
- return;
1029
- }
1030
- if (isCtrl(input, key, 'd')) {
1031
- dispatch({ type: 'apply', state: deleteForward(state) });
1032
- return;
1033
- }
1034
-
1035
- if (isCtrl(input, key, 'w')) {
1036
- const { next, killed } = killWordBackward(state);
1037
- pushKill(killed);
1038
- dispatch({ type: 'apply', state: next });
1039
- return;
1040
- }
1041
- if (key.meta && (input === '\b' || input === '\x7f')) {
1042
- const { next, killed } = killWordBackward(state);
1043
- pushKill(killed);
1044
- dispatch({ type: 'apply', state: next });
1045
- return;
1046
- }
1047
- if (key.meta && input === 'd') {
1048
- const { next, killed } = killWordForward(state);
1049
- pushKill(killed);
1050
- dispatch({ type: 'apply', state: next });
1051
- return;
1052
- }
1053
- if (key.meta && input === 'b') {
1054
- dispatch({ type: 'apply', state: moveWordLeft(state) });
1055
- return;
1056
- }
1057
- if (key.meta && input === 'f') {
1058
- dispatch({ type: 'apply', state: moveWordRight(state) });
1059
- return;
1060
- }
1061
-
1062
- if (!key.ctrl && !key.meta && input) {
1063
- if (input.includes('\n')) {
1064
- const lineCount = input.split('\n').length;
1065
- if (lineCount >= PASTE_BADGE_THRESHOLD) {
1066
- const next = insertBadge(state, { type: 'paste', text: input });
1067
- dispatch({ type: 'apply', state: { ...next, historyIdx: -1 } });
1068
- return;
1069
- }
1070
- }
1071
- const next = insertText(state, input);
1072
- dispatch({ type: 'apply', state: { ...next, historyIdx: -1 } });
1073
- }
1074
- }, [disabled, exit, handleClipboardImagePaste, handleKittyInput, handleSubmit, navigateHistory, state]);
1075
-
1076
- // Handle Kitty keyboard protocol sequences for overlay mode
1077
- const handleKittyOverlayInput = useCallback((char: string, key: InputKey, keycode: number, modifier: number) => {
1078
- if (disabled) return;
1079
-
1080
- const current = overlayStateRef.current;
1081
- const isKittyCtrl = modifier === 5;
1082
- const isKittyMeta = modifier === 9;
1083
- const lowerChar = char.toLowerCase();
1084
-
1085
- // Navigation
1086
- if (isKittyCtrl && lowerChar === 'a') {
1087
- overlayStateRef.current = { ...current, cursor: { index: 0, offset: 0 } };
1088
- renderOverlay(overlayStateRef.current);
1089
- return;
1090
- }
1091
- if (isKittyCtrl && lowerChar === 'e') {
1092
- overlayStateRef.current = { ...current, cursor: { index: current.segments.length, offset: 0 } };
1093
- renderOverlay(overlayStateRef.current);
1094
- return;
1095
- }
1096
- if (isKittyCtrl && lowerChar === 'b') {
1097
- overlayStateRef.current = moveLeft(current);
1098
- renderOverlay(overlayStateRef.current);
1099
- return;
1100
- }
1101
- if (isKittyCtrl && lowerChar === 'f') {
1102
- overlayStateRef.current = moveRight(current);
1103
- renderOverlay(overlayStateRef.current);
1104
- return;
1105
- }
1106
- if (isKittyCtrl && lowerChar === 'p') {
1107
- if (historyEnabled && current.history.length > 0) {
1108
- const newIdx = current.historyIdx === -1 ? current.history.length - 1 : Math.max(0, current.historyIdx - 1);
1109
- const historyValue = current.history[newIdx] || '';
1110
- overlayStateRef.current = {
1111
- ...current,
1112
- historyIdx: newIdx,
1113
- segments: historyValue.length > 0 ? [{ type: 'text', text: historyValue }] : [],
1114
- cursor: { index: historyValue.length > 0 ? 0 : 0, offset: historyValue.length }
1115
- };
1116
- renderOverlay(overlayStateRef.current);
1117
- }
1118
- return;
1119
- }
1120
- if (isKittyCtrl && lowerChar === 'n') {
1121
- if (historyEnabled && current.history.length > 0) {
1122
- let newIdx = current.historyIdx === -1 ? -1 : current.historyIdx + 1;
1123
- if (newIdx >= current.history.length) newIdx = -1;
1124
- if (newIdx === -1) {
1125
- overlayStateRef.current = { ...createEmptyState(), history: current.history, historyIdx: -1 };
1126
- } else {
1127
- const historyValue = current.history[newIdx];
1128
- overlayStateRef.current = {
1129
- ...current,
1130
- historyIdx: newIdx,
1131
- segments: historyValue.length > 0 ? [{ type: 'text', text: historyValue }] : [],
1132
- cursor: { index: historyValue.length > 0 ? 0 : 0, offset: historyValue.length }
1133
- };
1134
- }
1135
- renderOverlay(overlayStateRef.current);
1136
- }
1137
- return;
1138
- }
1139
-
1140
- // Kill/yank
1141
- if (isKittyCtrl && lowerChar === 'u') {
1142
- const { next, killed } = killToStart(current);
1143
- pushKill(killed);
1144
- overlayStateRef.current = { ...next, history: current.history, historyIdx: -1 };
1145
- renderOverlay(overlayStateRef.current);
1146
- return;
1147
- }
1148
- if (isKittyCtrl && lowerChar === 'k') {
1149
- const { next, killed } = killToEnd(current);
1150
- pushKill(killed);
1151
- overlayStateRef.current = next;
1152
- renderOverlay(overlayStateRef.current);
1153
- return;
1154
- }
1155
- if (isKittyCtrl && lowerChar === 'y') {
1156
- overlayStateRef.current = yank(current);
1157
- renderOverlay(overlayStateRef.current);
1158
- return;
1159
- }
1160
- if (isKittyCtrl && lowerChar === 'w') {
1161
- const { next, killed } = killWordBackward(current);
1162
- pushKill(killed);
1163
- overlayStateRef.current = next;
1164
- renderOverlay(overlayStateRef.current);
1165
- return;
1166
- }
1167
-
1168
- // Transpose
1169
- if (isKittyCtrl && lowerChar === 't') {
1170
- overlayStateRef.current = transposeChars(current);
1171
- renderOverlay(overlayStateRef.current);
1172
- return;
1173
- }
1174
-
1175
- // Delete forward
1176
- if (isKittyCtrl && lowerChar === 'd') {
1177
- overlayStateRef.current = deleteForward(current);
1178
- renderOverlay(overlayStateRef.current);
1179
- return;
1180
- }
1181
-
1182
- // Push-to-talk: Ctrl+R to toggle recording
1183
- if (isKittyCtrl && lowerChar === 'r') {
1184
- if (dictationBusyRef.current) return;
1185
-
1186
- if (isRecording()) {
1187
- dictationBusyRef.current = true;
1188
- onDictationStateChange?.('transcribing');
1189
-
1190
- if (useNative) {
1191
- stopNativeRecording()
1192
- .then((text) => {
1193
- if (text && text.trim()) {
1194
- // Submit the transcribed text directly
1195
- onSubmit(text.trim());
1196
- overlayStateRef.current = { ...overlayStateRef.current, segments: [], cursor: { index: 0, offset: 0 }, historyIdx: -1 };
1197
- renderOverlay(overlayStateRef.current);
1198
- } else {
1199
- onToast?.('No speech detected');
1200
- }
1201
- })
1202
- .catch((err) => {
1203
- onToast?.(`Dictation error: ${err.message}`);
1204
- })
1205
- .finally(() => {
1206
- dictationBusyRef.current = false;
1207
- onDictationStateChange?.('idle');
1208
- });
1209
- } else {
1210
- stopLegacyRecording()
1211
- .then((result) => {
1212
- if (result.text && result.text.trim()) {
1213
- // Submit the transcribed text directly
1214
- onSubmit(result.text.trim());
1215
- overlayStateRef.current = { ...overlayStateRef.current, segments: [], cursor: { index: 0, offset: 0 }, historyIdx: -1 };
1216
- renderOverlay(overlayStateRef.current);
1217
- } else {
1218
- onToast?.('No speech detected');
1219
- }
1220
- })
1221
- .catch((err) => {
1222
- onToast?.(`Dictation error: ${err.message}`);
1223
- })
1224
- .finally(() => {
1225
- dictationBusyRef.current = false;
1226
- onDictationStateChange?.('idle');
1227
- });
1228
- }
1229
- } else {
1230
- if (!isDictationAvailable()) {
1231
- onToast?.('Dictation not available. Build native/ztc-audio or install sox');
1232
- return;
1233
- }
1234
- try {
1235
- if (useNative) {
1236
- startNativeRecording({ model: 'tiny' });
1237
- } else {
1238
- startLegacyRecording();
1239
- }
1240
- onDictationStateChange?.('recording');
1241
- } catch (err) {
1242
- onToast?.(`Recording error: ${err instanceof Error ? err.message : 'Unknown'}`);
1243
- }
1244
- }
1245
- return;
1246
- }
1247
-
1248
- // Meta key combinations
1249
- if (isKittyMeta && lowerChar === 'y') {
1250
- overlayStateRef.current = yankPop(current);
1251
- renderOverlay(overlayStateRef.current);
1252
- return;
1253
- }
1254
- if (isKittyMeta && lowerChar === 't') {
1255
- overlayStateRef.current = transposeWords(current);
1256
- renderOverlay(overlayStateRef.current);
1257
- return;
1258
- }
1259
- if (isKittyMeta && lowerChar === 'd') {
1260
- const { next, killed } = killWordForward(current);
1261
- pushKill(killed);
1262
- overlayStateRef.current = next;
1263
- renderOverlay(overlayStateRef.current);
1264
- return;
1265
- }
1266
- if (isKittyMeta && lowerChar === 'b') {
1267
- overlayStateRef.current = moveWordLeft(current);
1268
- renderOverlay(overlayStateRef.current);
1269
- return;
1270
- }
1271
- if (isKittyMeta && lowerChar === 'f') {
1272
- overlayStateRef.current = moveWordRight(current);
1273
- renderOverlay(overlayStateRef.current);
1274
- return;
1275
- }
1276
-
1277
- // Unknown Kitty sequence - don't insert as text
1278
- }, [disabled, historyEnabled, killToEnd, killToStart, killWordBackward, killWordForward, onDictationStateChange, onToast, pushKill, renderOverlay, transposeChars, transposeWords, yank, yankPop]);
1279
-
1280
- const handleOverlayInput = useCallback((input: string, key: InputKey) => {
1281
- if (disabled) return;
1282
-
1283
- // Handle Kitty keyboard protocol sequences for overlay mode
1284
- const kittyMatch = input.match(/\x1b?\[(\d+);(\d+)u/);
1285
- if (kittyMatch) {
1286
- const keycode = parseInt(kittyMatch[1], 10);
1287
- const modifier = parseInt(kittyMatch[2], 10);
1288
- const kittyChar = String.fromCharCode(keycode);
1289
- handleKittyOverlayInput(kittyChar, key, keycode, modifier);
1290
- return;
1291
- }
1292
-
1293
- // Detect backspace via explicit key flag or known control codes
1294
- const backspaceFallback = input === '\b' || input === '\x7f' || input === '\x08';
1295
- const isBackspace = key.backspace || key.delete || backspaceFallback;
1296
-
1297
- // Ignore completely empty input events that aren't backspace
1298
- const safeKey = key as InputKey & { tab?: boolean; escape?: boolean };
1299
- if (
1300
- input === '' &&
1301
- !isBackspace &&
1302
- !safeKey.leftArrow &&
1303
- !safeKey.rightArrow &&
1304
- !safeKey.upArrow &&
1305
- !safeKey.downArrow &&
1306
- !safeKey.return &&
1307
- !safeKey.tab &&
1308
- !safeKey.escape &&
1309
- !safeKey.ctrl &&
1310
- !safeKey.meta
1311
- ) {
1312
- return;
1313
- }
1314
-
1315
- const current = overlayStateRef.current;
1316
-
1317
- if ((key.ctrl || key.meta) && (input === 'v' || input === '\u0016')) {
1318
- void handleClipboardImagePaste('overlay');
1319
- return;
1320
- }
1321
-
1322
- if (key.return && !key.shift) {
1323
- const serialized = serializeSegments(current.segments).trim();
1324
- if (serialized) {
1325
- const plainText = getPlainText(current.segments).trim();
1326
- const isCommand = current.segments.length === 1 && current.segments[0].type === 'text' && plainText.startsWith('/');
1327
- if (isCommand) {
1328
- const parts = plainText.slice(1).split(/\s+/);
1329
- const cmd = parts[0].toLowerCase();
1330
- const args = parts.slice(1);
1331
-
1332
- // Handle /pi (paste image) command directly
1333
- if (cmd === 'pi' || cmd === 'paste-image') {
1334
- overlayStateRef.current = { ...createEmptyState(), history: current.history, historyIdx: -1 };
1335
- renderOverlay(overlayStateRef.current);
1336
- void handleClipboardImagePaste('overlay');
1337
- return;
1338
- }
1339
-
1340
- onCommand?.(cmd, args);
1341
- } else {
1342
- onSubmit(serialized);
1343
- }
1344
- if (historyEnabled) {
1345
- current.history = [...current.history.slice(-100), serialized];
1346
- }
1347
- }
1348
- overlayStateRef.current = { ...createEmptyState(), history: current.history, historyIdx: -1 };
1349
- renderOverlay(overlayStateRef.current);
1350
- return;
1351
- }
1352
-
1353
- if (key.return && key.shift) {
1354
- overlayStateRef.current = { ...insertText(current, '\n'), historyIdx: -1 };
1355
- renderOverlay(overlayStateRef.current);
1356
- return;
1357
- }
1358
-
1359
- if (key.upArrow || key.downArrow) {
1360
- const direction = key.upArrow ? 'up' : 'down';
1361
- if (historyEnabled && current.history.length > 0) {
1362
- let newIdx: number;
1363
- if (direction === 'up') {
1364
- newIdx = current.historyIdx === -1 ? current.history.length - 1 : Math.max(0, current.historyIdx - 1);
1365
- } else {
1366
- newIdx = current.historyIdx === -1 ? -1 : current.historyIdx + 1;
1367
- if (newIdx >= current.history.length) newIdx = -1;
1368
- }
1369
- if (newIdx === -1) {
1370
- overlayStateRef.current = { ...createEmptyState(), history: current.history, historyIdx: -1 };
1371
- } else {
1372
- const historyValue = current.history[newIdx];
1373
- overlayStateRef.current = {
1374
- ...current,
1375
- historyIdx: newIdx,
1376
- segments: historyValue.length > 0 ? [{ type: 'text', text: historyValue }] : [],
1377
- cursor: { index: historyValue.length > 0 ? 0 : 0, offset: historyValue.length }
1378
- };
1379
- }
1380
- renderOverlay(overlayStateRef.current);
1381
- }
1382
- return;
1383
- }
1384
-
1385
- if (key.leftArrow) {
1386
- if (key.ctrl) {
1387
- overlayStateRef.current = moveWordLeft(current);
1388
- } else {
1389
- overlayStateRef.current = moveLeft(current);
1390
- }
1391
- renderOverlay(overlayStateRef.current);
1392
- return;
1393
- }
1394
-
1395
- if (key.rightArrow) {
1396
- if (key.ctrl) {
1397
- overlayStateRef.current = moveWordRight(current);
1398
- } else {
1399
- overlayStateRef.current = moveRight(current);
1400
- }
1401
- renderOverlay(overlayStateRef.current);
1402
- return;
1403
- }
1404
-
1405
- if (isCtrl(input, key, 'a')) {
1406
- overlayStateRef.current = { ...current, cursor: { index: 0, offset: 0 } };
1407
- renderOverlay(overlayStateRef.current);
1408
- return;
1409
- }
1410
-
1411
- if (isCtrl(input, key, 'e')) {
1412
- overlayStateRef.current = { ...current, cursor: { index: current.segments.length, offset: 0 } };
1413
- renderOverlay(overlayStateRef.current);
1414
- return;
1415
- }
1416
- if (isCtrl(input, key, 'b')) {
1417
- overlayStateRef.current = moveLeft(current);
1418
- renderOverlay(overlayStateRef.current);
1419
- return;
1420
- }
1421
- if (isCtrl(input, key, 'f')) {
1422
- overlayStateRef.current = moveRight(current);
1423
- renderOverlay(overlayStateRef.current);
1424
- return;
1425
- }
1426
- if (isCtrl(input, key, 'p')) {
1427
- const direction = 'up';
1428
- if (historyEnabled && current.history.length > 0) {
1429
- let newIdx = current.historyIdx === -1 ? current.history.length - 1 : Math.max(0, current.historyIdx - 1);
1430
- const historyValue = current.history[newIdx] || '';
1431
- overlayStateRef.current = {
1432
- ...current,
1433
- historyIdx: newIdx,
1434
- segments: historyValue.length > 0 ? [{ type: 'text', text: historyValue }] : [],
1435
- cursor: { index: historyValue.length > 0 ? 0 : 0, offset: historyValue.length }
1436
- };
1437
- renderOverlay(overlayStateRef.current);
1438
- }
1439
- return;
1440
- }
1441
- if (isCtrl(input, key, 'n')) {
1442
- if (historyEnabled && current.history.length > 0) {
1443
- let newIdx = current.historyIdx === -1 ? -1 : current.historyIdx + 1;
1444
- if (newIdx >= current.history.length) newIdx = -1;
1445
- if (newIdx === -1) {
1446
- overlayStateRef.current = { ...createEmptyState(), history: current.history, historyIdx: -1 };
1447
- } else {
1448
- const historyValue = current.history[newIdx];
1449
- overlayStateRef.current = {
1450
- ...current,
1451
- historyIdx: newIdx,
1452
- segments: historyValue.length > 0 ? [{ type: 'text', text: historyValue }] : [],
1453
- cursor: { index: historyValue.length > 0 ? 0 : 0, offset: historyValue.length }
1454
- };
1455
- }
1456
- renderOverlay(overlayStateRef.current);
1457
- }
1458
- return;
1459
- }
1460
-
1461
- if (isBackspace) {
1462
- // On macOS, backspace key reports as key.delete=true, so always use backspace() for backward deletion
1463
- overlayStateRef.current = backspace(current);
1464
- renderOverlay(overlayStateRef.current);
1465
- return;
1466
- }
1467
-
1468
- if (isCtrl(input, key, 'u')) {
1469
- const { next, killed } = killToStart(current);
1470
- pushKill(killed);
1471
- overlayStateRef.current = { ...next, history: current.history, historyIdx: -1 };
1472
- renderOverlay(overlayStateRef.current);
1473
- return;
1474
- }
1475
- if (isCtrl(input, key, 'k')) {
1476
- const { next, killed } = killToEnd(current);
1477
- pushKill(killed);
1478
- overlayStateRef.current = next;
1479
- renderOverlay(overlayStateRef.current);
1480
- return;
1481
- }
1482
- if (isCtrl(input, key, 'y')) {
1483
- overlayStateRef.current = yank(current);
1484
- renderOverlay(overlayStateRef.current);
1485
- return;
1486
- }
1487
- // Push-to-talk: Ctrl+R to toggle recording (overlay mode)
1488
- if (isCtrl(input, key, 'r')) {
1489
- if (dictationBusyRef.current) return;
1490
-
1491
- if (isRecording()) {
1492
- dictationBusyRef.current = true;
1493
- onDictationStateChange?.('transcribing');
1494
-
1495
- if (useNative) {
1496
- stopNativeRecording()
1497
- .then((text) => {
1498
- if (text && text.trim()) {
1499
- // Submit the transcribed text directly
1500
- onSubmit(text.trim());
1501
- overlayStateRef.current = { ...overlayStateRef.current, segments: [], cursor: { index: 0, offset: 0 }, historyIdx: -1 };
1502
- renderOverlay(overlayStateRef.current);
1503
- } else {
1504
- onToast?.('No speech detected');
1505
- }
1506
- })
1507
- .catch((err) => {
1508
- onToast?.(`Dictation error: ${err.message}`);
1509
- })
1510
- .finally(() => {
1511
- dictationBusyRef.current = false;
1512
- onDictationStateChange?.('idle');
1513
- });
1514
- } else {
1515
- stopLegacyRecording()
1516
- .then((result) => {
1517
- if (result.text && result.text.trim()) {
1518
- // Submit the transcribed text directly
1519
- onSubmit(result.text.trim());
1520
- overlayStateRef.current = { ...overlayStateRef.current, segments: [], cursor: { index: 0, offset: 0 }, historyIdx: -1 };
1521
- renderOverlay(overlayStateRef.current);
1522
- } else {
1523
- onToast?.('No speech detected');
1524
- }
1525
- })
1526
- .catch((err) => {
1527
- onToast?.(`Dictation error: ${err.message}`);
1528
- })
1529
- .finally(() => {
1530
- dictationBusyRef.current = false;
1531
- onDictationStateChange?.('idle');
1532
- });
1533
- }
1534
- } else {
1535
- if (!isDictationAvailable()) {
1536
- onToast?.('Dictation not available. Build native/ztc-audio or install sox');
1537
- return;
1538
- }
1539
- try {
1540
- if (useNative) {
1541
- startNativeRecording({ model: 'tiny' });
1542
- } else {
1543
- startLegacyRecording();
1544
- }
1545
- onDictationStateChange?.('recording');
1546
- } catch (err) {
1547
- onToast?.(`Recording error: ${err instanceof Error ? err.message : 'Unknown'}`);
1548
- }
1549
- }
1550
- return;
1551
- }
1552
- if (key.meta && input === 'y') {
1553
- overlayStateRef.current = yankPop(current);
1554
- renderOverlay(overlayStateRef.current);
1555
- return;
1556
- }
1557
- if (isCtrl(input, key, 't')) {
1558
- overlayStateRef.current = transposeChars(current);
1559
- renderOverlay(overlayStateRef.current);
1560
- return;
1561
- }
1562
- if (key.meta && input === 't') {
1563
- overlayStateRef.current = transposeWords(current);
1564
- renderOverlay(overlayStateRef.current);
1565
- return;
1566
- }
1567
- if (isCtrl(input, key, 'd')) {
1568
- overlayStateRef.current = deleteForward(current);
1569
- renderOverlay(overlayStateRef.current);
1570
- return;
1571
- }
1572
-
1573
- if (isCtrl(input, key, 'w')) {
1574
- const { next, killed } = killWordBackward(current);
1575
- pushKill(killed);
1576
- overlayStateRef.current = next;
1577
- renderOverlay(overlayStateRef.current);
1578
- return;
1579
- }
1580
- if (key.meta && (input === '\b' || input === '\x7f')) {
1581
- const { next, killed } = killWordBackward(current);
1582
- pushKill(killed);
1583
- overlayStateRef.current = next;
1584
- renderOverlay(overlayStateRef.current);
1585
- return;
1586
- }
1587
- if (key.meta && input === 'd') {
1588
- const { next, killed } = killWordForward(current);
1589
- pushKill(killed);
1590
- overlayStateRef.current = next;
1591
- renderOverlay(overlayStateRef.current);
1592
- return;
1593
- }
1594
- if (key.meta && input === 'b') {
1595
- overlayStateRef.current = moveWordLeft(current);
1596
- renderOverlay(overlayStateRef.current);
1597
- return;
1598
- }
1599
- if (key.meta && input === 'f') {
1600
- overlayStateRef.current = moveWordRight(current);
1601
- renderOverlay(overlayStateRef.current);
1602
- return;
1603
- }
1604
-
1605
- if (!key.ctrl && !key.meta && input) {
1606
- if (input.includes('\n')) {
1607
- const lineCount = input.split('\n').length;
1608
- if (lineCount >= PASTE_BADGE_THRESHOLD) {
1609
- overlayStateRef.current = insertBadge(current, { type: 'paste', text: input });
1610
- } else {
1611
- overlayStateRef.current = insertText(current, input);
1612
- }
1613
- } else {
1614
- overlayStateRef.current = insertText(current, input);
1615
- }
1616
- overlayStateRef.current = { ...overlayStateRef.current, historyIdx: -1 };
1617
- renderOverlay(overlayStateRef.current);
1618
- }
1619
- }, [disabled, handleKittyOverlayInput, historyEnabled, onCommand, onSubmit, renderOverlay]);
1620
-
1621
- useInput((input, key) => {
1622
- if (overlayEnabled) {
1623
- handleOverlayInput(input, key);
1624
- return;
1625
- }
1626
- handleInput(input, key);
1627
- });
1628
-
1629
- React.useEffect(() => {
1630
- if (!inputBus) return;
1631
- return inputBus.subscribe(({ input, key }) => {
1632
- if (overlayEnabled) {
1633
- handleOverlayInput(input, key);
1634
- return;
1635
- }
1636
- handleInput(input, key);
1637
- });
1638
- }, [handleInput, handleOverlayInput, inputBus, overlayEnabled]);
1639
-
1640
- React.useEffect(() => {
1641
- if (!overlayEnabled) return;
1642
- renderOverlay(overlayStateRef.current);
1643
- }, [overlayEnabled, renderOverlay]);
1644
-
1645
- const node = buildInputAreaView({
1646
- state,
1647
- placeholder,
1648
- disabled,
1649
- commands,
1650
- cols,
1651
- badgePreview,
1652
- showBadgePreview: true,
1653
- debug,
1654
- renderContent: !overlayEnabled
1655
- });
1656
-
1657
- return <InkNode node={node} />;
1658
- };
1659
-
1660
- export default InputArea;