zerg-ztc 0.1.3 → 0.1.5

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 (147) hide show
  1. package/dist/App.d.ts.map +1 -1
  2. package/dist/App.js +183 -19
  3. package/dist/App.js.map +1 -1
  4. package/dist/agent/agent.d.ts.map +1 -1
  5. package/dist/agent/agent.js +3 -1
  6. package/dist/agent/agent.js.map +1 -1
  7. package/dist/agent/commands/config.d.ts.map +1 -1
  8. package/dist/agent/commands/config.js +68 -2
  9. package/dist/agent/commands/config.js.map +1 -1
  10. package/dist/agent/commands/index.d.ts.map +1 -1
  11. package/dist/agent/commands/index.js +4 -1
  12. package/dist/agent/commands/index.js.map +1 -1
  13. package/dist/agent/commands/input_mode.d.ts +3 -0
  14. package/dist/agent/commands/input_mode.d.ts.map +1 -0
  15. package/dist/agent/commands/input_mode.js +21 -0
  16. package/dist/agent/commands/input_mode.js.map +1 -0
  17. package/dist/agent/commands/keybindings.d.ts +3 -0
  18. package/dist/agent/commands/keybindings.d.ts.map +1 -0
  19. package/dist/agent/commands/keybindings.js +38 -0
  20. package/dist/agent/commands/keybindings.js.map +1 -0
  21. package/dist/agent/commands/types.d.ts +2 -0
  22. package/dist/agent/commands/types.d.ts.map +1 -1
  23. package/dist/agent/commands/update.d.ts +3 -0
  24. package/dist/agent/commands/update.d.ts.map +1 -0
  25. package/dist/agent/commands/update.js +33 -0
  26. package/dist/agent/commands/update.js.map +1 -0
  27. package/dist/cli.js +68 -16
  28. package/dist/cli.js.map +1 -1
  29. package/dist/components/ActivityLine.d.ts +11 -0
  30. package/dist/components/ActivityLine.d.ts.map +1 -0
  31. package/dist/components/ActivityLine.js +9 -0
  32. package/dist/components/ActivityLine.js.map +1 -0
  33. package/dist/components/FullScreen.d.ts +1 -0
  34. package/dist/components/FullScreen.d.ts.map +1 -1
  35. package/dist/components/FullScreen.js +30 -30
  36. package/dist/components/FullScreen.js.map +1 -1
  37. package/dist/components/InputArea.d.ts.map +1 -1
  38. package/dist/components/InputArea.js +476 -19
  39. package/dist/components/InputArea.js.map +1 -1
  40. package/dist/components/MessageList.d.ts +2 -1
  41. package/dist/components/MessageList.d.ts.map +1 -1
  42. package/dist/components/MessageList.js +41 -2
  43. package/dist/components/MessageList.js.map +1 -1
  44. package/dist/components/SingleMessage.d.ts +9 -0
  45. package/dist/components/SingleMessage.d.ts.map +1 -0
  46. package/dist/components/SingleMessage.js +27 -0
  47. package/dist/components/SingleMessage.js.map +1 -0
  48. package/dist/components/StatusBar.d.ts +2 -0
  49. package/dist/components/StatusBar.d.ts.map +1 -1
  50. package/dist/components/StatusBar.js +3 -1
  51. package/dist/components/StatusBar.js.map +1 -1
  52. package/dist/components/index.d.ts +2 -0
  53. package/dist/components/index.d.ts.map +1 -1
  54. package/dist/components/index.js +2 -0
  55. package/dist/components/index.js.map +1 -1
  56. package/dist/config/types.d.ts +1 -0
  57. package/dist/config/types.d.ts.map +1 -1
  58. package/dist/config.d.ts.map +1 -1
  59. package/dist/config.js +8 -0
  60. package/dist/config.js.map +1 -1
  61. package/dist/types.d.ts +1 -0
  62. package/dist/types.d.ts.map +1 -1
  63. package/dist/ui/core/input_segments.d.ts +1 -0
  64. package/dist/ui/core/input_segments.d.ts.map +1 -1
  65. package/dist/ui/core/input_segments.js +46 -14
  66. package/dist/ui/core/input_segments.js.map +1 -1
  67. package/dist/ui/core/types.d.ts +1 -0
  68. package/dist/ui/core/types.d.ts.map +1 -1
  69. package/dist/ui/ink/render.d.ts +3 -1
  70. package/dist/ui/ink/render.d.ts.map +1 -1
  71. package/dist/ui/ink/render.js +7 -5
  72. package/dist/ui/ink/render.js.map +1 -1
  73. package/dist/ui/views/activity_line.d.ts +11 -0
  74. package/dist/ui/views/activity_line.d.ts.map +1 -0
  75. package/dist/ui/views/activity_line.js +20 -0
  76. package/dist/ui/views/activity_line.js.map +1 -0
  77. package/dist/ui/views/app.d.ts +5 -1
  78. package/dist/ui/views/app.d.ts.map +1 -1
  79. package/dist/ui/views/app.js +18 -14
  80. package/dist/ui/views/app.js.map +1 -1
  81. package/dist/ui/views/header.d.ts.map +1 -1
  82. package/dist/ui/views/header.js +7 -5
  83. package/dist/ui/views/header.js.map +1 -1
  84. package/dist/ui/views/input_area.d.ts.map +1 -1
  85. package/dist/ui/views/input_area.js +25 -12
  86. package/dist/ui/views/input_area.js.map +1 -1
  87. package/dist/ui/views/message_list.d.ts +3 -2
  88. package/dist/ui/views/message_list.d.ts.map +1 -1
  89. package/dist/ui/views/message_list.js +33 -19
  90. package/dist/ui/views/message_list.js.map +1 -1
  91. package/dist/ui/views/status_bar.d.ts +3 -1
  92. package/dist/ui/views/status_bar.d.ts.map +1 -1
  93. package/dist/ui/views/status_bar.js +8 -2
  94. package/dist/ui/views/status_bar.js.map +1 -1
  95. package/dist/utils/spinner_frames.d.ts +2 -0
  96. package/dist/utils/spinner_frames.d.ts.map +1 -0
  97. package/dist/utils/spinner_frames.js +2 -0
  98. package/dist/utils/spinner_frames.js.map +1 -0
  99. package/dist/utils/spinner_verbs.d.ts +4 -0
  100. package/dist/utils/spinner_verbs.d.ts.map +1 -0
  101. package/dist/utils/spinner_verbs.js +22 -0
  102. package/dist/utils/spinner_verbs.js.map +1 -0
  103. package/dist/utils/tool_trace.d.ts.map +1 -1
  104. package/dist/utils/tool_trace.js +12 -2
  105. package/dist/utils/tool_trace.js.map +1 -1
  106. package/dist/utils/update.d.ts +9 -0
  107. package/dist/utils/update.d.ts.map +1 -0
  108. package/dist/utils/update.js +37 -0
  109. package/dist/utils/update.js.map +1 -0
  110. package/dist/utils/version.d.ts +2 -0
  111. package/dist/utils/version.d.ts.map +1 -0
  112. package/dist/utils/version.js +16 -0
  113. package/dist/utils/version.js.map +1 -0
  114. package/package.json +1 -1
  115. package/src/App.tsx +226 -32
  116. package/src/agent/agent.ts +3 -1
  117. package/src/agent/commands/config.ts +76 -2
  118. package/src/agent/commands/index.ts +6 -0
  119. package/src/agent/commands/input_mode.ts +22 -0
  120. package/src/agent/commands/keybindings.ts +40 -0
  121. package/src/agent/commands/types.ts +2 -0
  122. package/src/agent/commands/update.ts +32 -0
  123. package/src/cli.tsx +77 -15
  124. package/src/components/ActivityLine.tsx +23 -0
  125. package/src/components/FullScreen.tsx +41 -35
  126. package/src/components/InputArea.tsx +489 -19
  127. package/src/components/MessageList.tsx +52 -6
  128. package/src/components/SingleMessage.tsx +59 -0
  129. package/src/components/StatusBar.tsx +6 -0
  130. package/src/components/index.tsx +3 -1
  131. package/src/config/types.ts +1 -0
  132. package/src/config.ts +8 -0
  133. package/src/types.ts +1 -0
  134. package/src/ui/core/input_segments.ts +49 -14
  135. package/src/ui/core/types.ts +1 -0
  136. package/src/ui/ink/render.tsx +16 -5
  137. package/src/ui/views/activity_line.ts +33 -0
  138. package/src/ui/views/app.ts +25 -13
  139. package/src/ui/views/header.ts +7 -5
  140. package/src/ui/views/input_area.ts +28 -17
  141. package/src/ui/views/message_list.ts +36 -20
  142. package/src/ui/views/status_bar.ts +11 -1
  143. package/src/utils/spinner_frames.ts +1 -0
  144. package/src/utils/spinner_verbs.ts +23 -0
  145. package/src/utils/tool_trace.ts +12 -2
  146. package/src/utils/update.ts +44 -0
  147. package/src/utils/version.ts +15 -0
package/src/App.tsx CHANGED
@@ -1,12 +1,13 @@
1
1
  import React, { useState, useCallback, useMemo, useRef } from 'react';
2
- import { Box, useApp, useInput } from 'ink';
3
- import { Header, MessageList, InputArea, StatusBar, FullScreen, useScreenSize } from './components/index.js';
2
+ import { Box, useApp, useInput, Static } from 'ink';
3
+ import { Header, MessageList, SingleMessage, InputArea, StatusBar, FullScreen, ActivityLine, useScreenSize } from './components/index.js';
4
4
  import { buildAppView } from './ui/views/app.js';
5
5
  import { useMirror } from './web/mirror_hook.js';
6
6
  import { Agent } from './agent/index.js';
7
7
  import { commands, type CommandContext } from './agent/commands/index.js';
8
- import { Message, AgentState } from './types.js';
8
+ import { Message, AgentState, InputMode } from './types.js';
9
9
  import { InputState } from './ui/core/input_state.js';
10
+ import { estimateInputLines } from './ui/views/input_area.js';
10
11
  import { configStore } from './config.js';
11
12
  import { createInputBus } from './ui/core/input.js';
12
13
  import { loadEmulationProfiles, getEmulationProfile } from './emulation/catalog.js';
@@ -20,6 +21,10 @@ import { autoActivateSkills, buildSkillPrompt } from './skills/index.js';
20
21
  import { getSkillRegistry } from './skills/registry.js';
21
22
  import { Skill } from './skills/types.js';
22
23
  import { createAgentFromConfig } from './agent/factory.js';
24
+ import { checkForUpdate } from './utils/update.js';
25
+ import { getVersion } from './utils/version.js';
26
+ import { DEFAULT_SPINNER_VERBS } from './utils/spinner_verbs.js';
27
+ import { SPINNER_FRAMES } from './utils/spinner_frames.js';
23
28
 
24
29
  // --- Utilities ---
25
30
 
@@ -68,11 +73,17 @@ export const App: React.FC = () => {
68
73
 
69
74
  // State
70
75
  const [messages, setMessages] = useState<Message[]>([getWelcomeMessage()]);
76
+ const messagesRef = useRef<Message[]>(messages);
71
77
  const [agentState, setAgentState] = useState<AgentState>({ status: 'idle' });
72
78
  const [sessionId] = useState(generateId());
73
79
  const [agent, setAgent] = useState<Agent | null>(createAgent);
74
80
  const [expandToolOutputs, setExpandToolOutputs] = useState(false);
75
81
  const [skills, setSkills] = useState<Skill[]>([]);
82
+ const [inputMode, setInputMode] = useState<InputMode>('queue');
83
+ const queueRef = useRef<string[]>([]);
84
+ const activeRunIdRef = useRef<string | null>(null);
85
+ const runCounterRef = useRef(0);
86
+ const lastRunDurationRef = useRef<number | null>(null);
76
87
 
77
88
  React.useEffect(() => {
78
89
  let active = true;
@@ -101,6 +112,8 @@ export const App: React.FC = () => {
101
112
  const [retryAvailable, setRetryAvailable] = useState(false);
102
113
  const [toast, setToast] = useState<string | null>(null);
103
114
  const toastTimerRef = useRef<NodeJS.Timeout | null>(null);
115
+ const [spinnerLabel, setSpinnerLabel] = useState<string | null>(null);
116
+ const [spinnerFrame, setSpinnerFrame] = useState<string | null>(null);
104
117
  const streamingMessageId = React.useRef<string | null>(null);
105
118
  const streamedResponse = React.useRef(false);
106
119
  const toolStartTimes = React.useRef<Map<string, number[]>>(new Map());
@@ -112,21 +125,80 @@ export const App: React.FC = () => {
112
125
  );
113
126
  const contextLength = agentState.contextTokens ?? fallbackContextLength;
114
127
  const contextEstimated = agentState.contextTokens ? agentState.tokensEstimated : true;
128
+ const spinnerVerbs = configStore.get().spinnerVerbs || DEFAULT_SPINNER_VERBS;
129
+ const spinnerVerbsKey = spinnerVerbs.join('|');
115
130
 
116
131
  React.useEffect(() => {
117
132
  renderCount.current += 1;
118
133
  debugLog(`App render #${renderCount.current} (messages=${messages.length}, state=${agentState.status})`);
119
134
  });
135
+ React.useEffect(() => {
136
+ messagesRef.current = messages;
137
+ }, [messages]);
120
138
  const [debug, setDebug] = useState(false);
139
+ const scrollback = process.env.ZTC_SCROLLBACK === '1' || process.env.ZTC_ALT_SCREEN !== '1';
140
+
141
+ React.useEffect(() => {
142
+ if (agentState.status !== 'thinking' && agentState.status !== 'streaming') {
143
+ setSpinnerLabel(null);
144
+ setSpinnerFrame(null);
145
+ return;
146
+ }
147
+ if (spinnerVerbs.length === 0) {
148
+ setSpinnerLabel(null);
149
+ setSpinnerFrame(null);
150
+ return;
151
+ }
152
+ const verb = spinnerVerbs[Math.floor(Math.random() * spinnerVerbs.length)];
153
+ setSpinnerLabel(verb);
154
+ }, [agentState.status, spinnerVerbsKey]);
121
155
 
122
- const inputHeight = 5; // 1 input line + 4 suggestion lines
123
- const headerHeight = 1;
156
+ React.useEffect(() => {
157
+ if (agentState.status !== 'thinking' && agentState.status !== 'streaming') {
158
+ setSpinnerFrame(null);
159
+ return;
160
+ }
161
+ // Animation is now safe in scrollback mode because we use Ink's Static component
162
+ // for completed messages - only the live section below Static re-renders
163
+ let index = 0;
164
+ setSpinnerFrame(SPINNER_FRAMES[index]);
165
+ const interval = setInterval(() => {
166
+ index = (index + 1) % SPINNER_FRAMES.length;
167
+ setSpinnerFrame(SPINNER_FRAMES[index]);
168
+ }, 120);
169
+ return () => {
170
+ clearInterval(interval);
171
+ };
172
+ }, [agentState.status]);
173
+
174
+ const headerHeight = scrollback ? 0 : 4; // border (2) + content (1) + margin (1)
124
175
  const statusHeight = 1;
176
+ const suggestionLines = 4;
177
+
178
+ const estimateBadgePreviewLines = useCallback((state: InputState): number => {
179
+ const segment = state.segments[state.cursor.index];
180
+ if (!segment || segment.type === 'text') return 0;
181
+ if (segment.type === 'paste') {
182
+ const lines = segment.text.split('\n');
183
+ return Math.min(3, lines.length) + (lines.length > 3 ? 1 : 0);
184
+ }
185
+ if (segment.type === 'image') {
186
+ return 16;
187
+ }
188
+ return 1;
189
+ }, []);
190
+
191
+ const inputHeight = useMemo(() => {
192
+ const inputLineCount = estimateInputLines(inputSnapshot.segments, columns);
193
+ const previewLines = estimateBadgePreviewLines(inputSnapshot);
194
+ const maxInputLines = Math.max(1, rows - (headerHeight + statusHeight + suggestionLines + 5));
195
+ return Math.min(Math.max(1, inputLineCount + previewLines), maxInputLines) + suggestionLines;
196
+ }, [columns, estimateBadgePreviewLines, inputSnapshot, rows]);
125
197
 
126
198
  // Calculate content height (total - header - input - status)
127
199
  const contentHeight = useMemo(
128
- () => Math.max(rows - (headerHeight + inputHeight + statusHeight), 5),
129
- [rows]
200
+ () => scrollback ? undefined : Math.max(rows - (headerHeight + inputHeight + statusHeight), 5),
201
+ [rows, scrollback, inputHeight]
130
202
  );
131
203
 
132
204
  // Reload agent when config changes
@@ -172,6 +244,21 @@ export const App: React.FC = () => {
172
244
  }]);
173
245
  }, []);
174
246
 
247
+ React.useEffect(() => {
248
+ if (process.env.ZTC_DISABLE_UPDATE === '1') return;
249
+ if (process.env.ZTC_WEB_MIRROR === '1') return;
250
+ let active = true;
251
+ const current = getVersion();
252
+ checkForUpdate(current).then(info => {
253
+ if (!active || !info.hasUpdate) return;
254
+ addMessage({
255
+ role: 'system',
256
+ content: `Update available: v${info.latest} (current v${info.current}). Run /update to install.`
257
+ });
258
+ }).catch(() => {});
259
+ return () => { active = false; };
260
+ }, [addMessage]);
261
+
175
262
  const clearMessages = useCallback(() => {
176
263
  setMessages([]);
177
264
  }, []);
@@ -193,15 +280,22 @@ export const App: React.FC = () => {
193
280
  return lower.includes('overloaded') || lower.includes('529') || lower.includes('429') || lower.includes('rate limit');
194
281
  }, []);
195
282
 
196
- const runWithRetry = useCallback(async (requestMessages: Message[], runAgent: Agent, isManual = false) => {
283
+ const runWithRetry = useCallback(async (
284
+ requestMessages: Message[],
285
+ runAgent: Agent,
286
+ isManual = false,
287
+ isActive?: () => boolean
288
+ ) => {
197
289
  const maxRetries = 3;
198
290
  let attempt = 0;
199
291
  setRetryAvailable(false);
200
292
  while (attempt < maxRetries) {
201
293
  attempt += 1;
294
+ if (isActive && !isActive()) return;
202
295
  try {
203
296
  streamedResponse.current = false;
204
297
  const result = await runAgent.run(requestMessages);
298
+ if (isActive && !isActive()) return;
205
299
  if (!streamedResponse.current) {
206
300
  addMessage({
207
301
  role: 'assistant',
@@ -220,6 +314,7 @@ export const App: React.FC = () => {
220
314
  return;
221
315
  } catch (err) {
222
316
  const errorMsg = (err as Error).message || 'Agent error';
317
+ if (isActive && !isActive()) return;
223
318
  if (!isRetryableError(errorMsg) || attempt >= maxRetries) {
224
319
  throw err;
225
320
  }
@@ -242,9 +337,14 @@ export const App: React.FC = () => {
242
337
  addMessage({ role: 'system', content: 'No previous request to retry.' });
243
338
  return;
244
339
  }
340
+ const runId = `${Date.now()}_${runCounterRef.current++}`;
341
+ activeRunIdRef.current = runId;
342
+ const isActive = () => activeRunIdRef.current === runId;
343
+ const runStartedAt = Date.now();
245
344
  setAgentState({ status: 'thinking', startedAt: new Date() });
246
- void runWithRetry(last.messages, last.agent, true).catch((err) => {
345
+ void runWithRetry(last.messages, last.agent, true, isActive).catch((err) => {
247
346
  const message = (err as Error).message || 'Agent error';
347
+ if (!isActive()) return;
248
348
  addMessage({ role: 'system', content: `Error: ${message}` });
249
349
  if (isRetryableError(message)) {
250
350
  addMessage({ role: 'system', content: 'Retries exhausted. Use /retry to try again.' });
@@ -282,8 +382,10 @@ export const App: React.FC = () => {
282
382
  },
283
383
  skills: {
284
384
  list: async () => getSkillRegistry()
285
- }
286
- }), [addMessage, clearMessages, getMessages, reloadAgent, exit, shellController, retryLast]);
385
+ },
386
+ getInputMode: () => inputMode,
387
+ setInputMode: (mode) => setInputMode(mode)
388
+ }), [addMessage, clearMessages, getMessages, reloadAgent, exit, shellController, retryLast, inputMode]);
287
389
 
288
390
  // Handle commands
289
391
  const handleCommand = useCallback((cmd: string, args: string[]) => {
@@ -322,6 +424,22 @@ export const App: React.FC = () => {
322
424
  return;
323
425
  }
324
426
 
427
+ const busy = agentState.status !== 'idle' && agentState.status !== 'error';
428
+ if (busy) {
429
+ if (inputMode === 'queue') {
430
+ queueRef.current.push(text);
431
+ addMessage({
432
+ role: 'system',
433
+ content: `Queued (${queueRef.current.length})`
434
+ });
435
+ return;
436
+ }
437
+ addMessage({
438
+ role: 'system',
439
+ content: 'Interrupting current response...'
440
+ });
441
+ }
442
+
325
443
  let currentAgent = agent;
326
444
 
327
445
  if (skills.length > 0) {
@@ -373,12 +491,19 @@ export const App: React.FC = () => {
373
491
  timestamp: new Date()
374
492
  };
375
493
  setMessages(prev => [...prev, userMsg]);
376
- const requestMessages = [...messages, userMsg];
494
+ const requestMessages = [...messagesRef.current, userMsg];
377
495
  lastRequestRef.current = { messages: requestMessages, agent: currentAgent };
378
496
 
497
+ const runId = `${Date.now()}_${runCounterRef.current++}`;
498
+ activeRunIdRef.current = runId;
499
+ const isActive = () => activeRunIdRef.current === runId;
500
+ const runStartedAt = Date.now();
379
501
  setAgentState({ status: 'thinking', startedAt: new Date() });
502
+ streamingMessageId.current = null;
503
+ streamedResponse.current = false;
380
504
 
381
505
  const cleanup = currentAgent.on((event) => {
506
+ if (!isActive()) return;
382
507
  switch (event.type) {
383
508
  case 'thinking_start':
384
509
  setAgentState(s => ({ ...s, status: 'thinking' }));
@@ -436,7 +561,7 @@ export const App: React.FC = () => {
436
561
  toolStartTimes.current.set(key, bucket);
437
562
  const duration = started ? Date.now() - started : undefined;
438
563
  const emulationId = configStore.getEmulationId();
439
- if (getTraceStyle(emulationId) === 'claude_code') {
564
+ if (getTraceStyle(emulationId) === 'claude_code' || getTraceStyle(emulationId) === 'codex') {
440
565
  const output = buildToolOutputMessage(event.tool, event.result, duration, emulationId);
441
566
  addMessage({
442
567
  role: 'tool',
@@ -475,9 +600,10 @@ export const App: React.FC = () => {
475
600
  });
476
601
 
477
602
  try {
478
- await runWithRetry(requestMessages, currentAgent);
603
+ await runWithRetry(requestMessages, currentAgent, false, isActive);
479
604
  } catch (err) {
480
605
  const errorMsg = (err as Error).message;
606
+ if (!isActive()) return;
481
607
 
482
608
  const isAuthError = errorMsg.includes('401') || errorMsg.includes('authentication');
483
609
  if (isAuthError) {
@@ -504,11 +630,30 @@ export const App: React.FC = () => {
504
630
  setAgentState({ status: 'idle' });
505
631
  }, 3000);
506
632
  } finally {
507
- streamedResponse.current = false;
508
- streamingMessageId.current = null;
509
- cleanup();
633
+ if (isActive()) {
634
+ streamedResponse.current = false;
635
+ streamingMessageId.current = null;
636
+ activeRunIdRef.current = null;
637
+ lastRunDurationRef.current = Date.now() - runStartedAt;
638
+ if (getTraceStyle(configStore.getEmulationId()) === 'codex') {
639
+ const seconds = Math.max(0, Math.round((lastRunDurationRef.current || 0) / 1000));
640
+ addMessage({
641
+ role: 'tool',
642
+ content: `✻ Worked for ${seconds}s`
643
+ });
644
+ }
645
+ cleanup();
646
+ if (inputMode === 'queue' && queueRef.current.length > 0) {
647
+ const next = queueRef.current.shift();
648
+ if (next) {
649
+ void handleSubmit(next);
650
+ }
651
+ }
652
+ } else {
653
+ cleanup();
654
+ }
510
655
  }
511
- }, [messages, agent, addMessage, isRetryableError, runWithRetry]);
656
+ }, [agent, agentState.status, addMessage, handleCommand, inputMode, isRetryableError, runWithRetry]);
512
657
 
513
658
  const layoutTree = useMemo(() => {
514
659
  if (!mirrorEnabled) return null;
@@ -521,16 +666,20 @@ export const App: React.FC = () => {
521
666
  rows,
522
667
  commands,
523
668
  hasApiKey: configStore.hasApiKey(),
669
+ version: getVersion(),
524
670
  contextLength,
525
671
  contextEstimated,
526
672
  provider,
527
673
  model,
528
674
  emulationId,
675
+ inputMode,
529
676
  toast,
677
+ spinnerLabel,
678
+ spinnerFrame,
530
679
  debug,
531
680
  expandToolOutputs
532
681
  });
533
- }, [mirrorEnabled, messages, agentState, inputSnapshot, sessionId, rows, contextLength, contextEstimated, provider, model, emulationId, toast, debug, expandToolOutputs]);
682
+ }, [mirrorEnabled, messages, agentState, inputSnapshot, sessionId, rows, contextLength, contextEstimated, provider, model, emulationId, inputMode, toast, spinnerLabel, spinnerFrame, debug, expandToolOutputs]);
534
683
 
535
684
  const showToast = useCallback((message: string) => {
536
685
  setToast(message);
@@ -544,26 +693,70 @@ export const App: React.FC = () => {
544
693
 
545
694
  useMirror(layoutTree, inputBus);
546
695
 
696
+ // Compute messages for Static (completed) vs live (streaming)
697
+ // Static component handles deduplication by key - it only renders new items
698
+ const { staticMessages, streamingMessage } = useMemo(() => {
699
+ if (!scrollback) {
700
+ return { staticMessages: [], streamingMessage: null };
701
+ }
702
+
703
+ // Completed messages go to Static (rendered once, kept in scrollback)
704
+ const completed = messages.filter(m => !m.isStreaming);
705
+ // Streaming message stays in live section (can update)
706
+ const streaming = messages.find(m => m.isStreaming) || null;
707
+
708
+ return {
709
+ staticMessages: completed,
710
+ streamingMessage: streaming
711
+ };
712
+ }, [messages, scrollback]);
713
+
547
714
  return (
548
- <FullScreen debug={debug}>
549
- <Header version="0.1.0" debug={debug} />
550
-
551
- <MessageList
552
- messages={messages}
553
- height={contentHeight}
554
- debug={debug}
555
- expandToolOutputs={expandToolOutputs}
715
+ <FullScreen debug={debug} scrollback={scrollback}>
716
+ {!scrollback && <Header version={getVersion()} debug={debug} />}
717
+
718
+ {scrollback ? (
719
+ <Box flexDirection="column">
720
+ {/* Static: each message rendered ONCE, stays in scrollback buffer */}
721
+ <Static items={staticMessages}>
722
+ {(msg: Message) => (
723
+ <Box key={msg.id} flexDirection="column">
724
+ <SingleMessage message={msg} expandToolOutputs={expandToolOutputs} />
725
+ </Box>
726
+ )}
727
+ </Static>
728
+
729
+ {/* Live section: streaming message can update */}
730
+ {streamingMessage && (
731
+ <SingleMessage message={streamingMessage} expandToolOutputs={expandToolOutputs} />
732
+ )}
733
+ </Box>
734
+ ) : (
735
+ <MessageList
736
+ messages={messages}
737
+ height={contentHeight}
738
+ debug={debug}
739
+ expandToolOutputs={expandToolOutputs}
740
+ scrollback={scrollback}
741
+ />
742
+ )}
743
+
744
+ <ActivityLine
745
+ state={agentState}
746
+ spinnerLabel={spinnerLabel}
747
+ spinnerFrame={spinnerFrame}
748
+ inputMode={inputMode}
556
749
  />
557
-
750
+
558
751
  <InputArea
559
752
  onSubmit={handleSubmit}
560
753
  onCommand={handleCommand}
561
754
  commands={commands}
562
- onStateChange={mirrorEnabled ? setInputSnapshot : undefined}
755
+ onStateChange={setInputSnapshot}
563
756
  onToast={showToast}
564
757
  cols={columns}
565
758
  inputBus={inputBus}
566
- disabled={agentState.status !== 'idle' && agentState.status !== 'error'}
759
+ disabled={false}
567
760
  debug={debug}
568
761
  placeholder={
569
762
  !configStore.hasApiKey() ? 'Set API key with /config key <key>' :
@@ -573,17 +766,18 @@ export const App: React.FC = () => {
573
766
  'Type a message or /help for commands...'
574
767
  }
575
768
  />
576
-
769
+
577
770
  <StatusBar
578
771
  state={agentState}
579
772
  sessionId={sessionId}
580
- version="0.1.0"
773
+ version={getVersion()}
581
774
  connectionStatus={configStore.hasApiKey() ? 'connected' : 'disconnected'}
582
775
  contextLength={contextLength}
583
776
  contextEstimated={contextEstimated}
584
777
  provider={provider}
585
778
  model={model}
586
779
  emulationId={emulationId}
780
+ inputMode={inputMode}
587
781
  toast={toast}
588
782
  debug={debug}
589
783
  />
@@ -77,7 +77,9 @@ You have access to tools for:
77
77
  - Running shell commands
78
78
  - Querying the Zerg system
79
79
 
80
- Be concise and helpful. When using tools, explain what you're doing briefly. If a task requires multiple steps, proceed through them systematically.`;
80
+ Be concise and helpful. When using tools, explain what you're doing briefly. If a task requires multiple steps, proceed through them systematically.
81
+
82
+ When a user intent maps to an available slash command, invoke the command directly (just the command) instead of explaining how to do it. Prefer executing commands over describing them.`;
81
83
  }
82
84
 
83
85
  // Event handling
@@ -1,9 +1,10 @@
1
1
  import { Command } from './types.js';
2
+ import { DEFAULT_SPINNER_VERBS, formatSpinnerVerbs, parseSpinnerVerbs } from '../../utils/spinner_verbs.js';
2
3
 
3
4
  export const configCommand: Command = {
4
5
  name: 'config',
5
6
  description: 'Manage configuration',
6
- usage: '<show|key|provider|endpoint|model> [value]',
7
+ usage: '<show|key|provider|endpoint|model|spinner> [value]',
7
8
  handler: async (args, ctx) => {
8
9
  const [subCmd, ...rest] = args;
9
10
  const value = rest.join(' ');
@@ -24,6 +25,7 @@ export const configCommand: Command = {
24
25
  ` Max Tokens: ${config.maxTokens}`,
25
26
  ` Zerg Endpoint: ${config.zergEndpoint || '(not set)'}`,
26
27
  ` Emulation: ${ctx.config.getEmulationId() || '(none)'}`,
28
+ ` Spinner verbs: ${formatSpinnerVerbs(config.spinnerVerbs || DEFAULT_SPINNER_VERBS)}`,
27
29
  '',
28
30
  `Config storage: ${ctx.config.locationLabel || '~/.ztc/config.json'}`
29
31
  ].join('\n')
@@ -120,10 +122,82 @@ export const configCommand: Command = {
120
122
  });
121
123
  break;
122
124
 
125
+ case 'spinner': {
126
+ const [action, ...restParts] = rest;
127
+ const actionValue = restParts.join(' ');
128
+ const current = ctx.config.get().spinnerVerbs || DEFAULT_SPINNER_VERBS;
129
+
130
+ if (!action || action === 'show') {
131
+ ctx.addMessage({
132
+ role: 'system',
133
+ content: `Spinner verbs:\n ${formatSpinnerVerbs(current)}\n\nUsage:\n /config spinner show\n /config spinner set <verb1, verb2, ...>\n /config spinner add <verb>\n /config spinner remove <verb>\n /config spinner reset\n /config spinner off`
134
+ });
135
+ return;
136
+ }
137
+
138
+ if (action === 'reset') {
139
+ ctx.config.set('spinnerVerbs', DEFAULT_SPINNER_VERBS);
140
+ ctx.config.save();
141
+ ctx.addMessage({ role: 'system', content: '✓ Spinner verbs reset to defaults.' });
142
+ return;
143
+ }
144
+
145
+ if (action === 'off') {
146
+ ctx.config.set('spinnerVerbs', []);
147
+ ctx.config.save();
148
+ ctx.addMessage({ role: 'system', content: '✓ Spinner verbs disabled.' });
149
+ return;
150
+ }
151
+
152
+ if (action === 'set') {
153
+ const verbs = parseSpinnerVerbs(actionValue);
154
+ if (verbs.length === 0) {
155
+ ctx.addMessage({ role: 'system', content: 'Usage: /config spinner set <verb1, verb2, ...>' });
156
+ return;
157
+ }
158
+ ctx.config.set('spinnerVerbs', verbs);
159
+ ctx.config.save();
160
+ ctx.addMessage({ role: 'system', content: `✓ Spinner verbs updated: ${formatSpinnerVerbs(verbs)}` });
161
+ return;
162
+ }
163
+
164
+ if (action === 'add') {
165
+ const verbs = parseSpinnerVerbs(actionValue);
166
+ if (verbs.length === 0) {
167
+ ctx.addMessage({ role: 'system', content: 'Usage: /config spinner add <verb>' });
168
+ return;
169
+ }
170
+ const next = [...current, ...verbs];
171
+ ctx.config.set('spinnerVerbs', next);
172
+ ctx.config.save();
173
+ ctx.addMessage({ role: 'system', content: `✓ Added spinner verbs: ${formatSpinnerVerbs(verbs)}` });
174
+ return;
175
+ }
176
+
177
+ if (action === 'remove') {
178
+ const verbs = parseSpinnerVerbs(actionValue).map(v => v.toLowerCase());
179
+ if (verbs.length === 0) {
180
+ ctx.addMessage({ role: 'system', content: 'Usage: /config spinner remove <verb>' });
181
+ return;
182
+ }
183
+ const next = current.filter(v => !verbs.includes(v.toLowerCase()));
184
+ ctx.config.set('spinnerVerbs', next);
185
+ ctx.config.save();
186
+ ctx.addMessage({ role: 'system', content: `✓ Removed spinner verbs: ${formatSpinnerVerbs(verbs)}` });
187
+ return;
188
+ }
189
+
190
+ ctx.addMessage({
191
+ role: 'system',
192
+ content: 'Usage: /config spinner <show|set|add|remove|reset|off> [value]'
193
+ });
194
+ break;
195
+ }
196
+
123
197
  default:
124
198
  ctx.addMessage({
125
199
  role: 'system',
126
- content: 'Usage: /config <show|key|provider|endpoint|model> [value]\n\nExamples:\n /config show Show current config\n /config key sk-ant-... Set API key (current provider)\n /config key openai sk-... Set API key for provider\n /config provider openai Set provider\n /config endpoint https://api.example.com/v1 Set OpenAI-compatible base URL\n /config model claude-opus-4-20250514 Set model'
200
+ content: 'Usage: /config <show|key|provider|endpoint|model|spinner> [value]\n\nExamples:\n /config show Show current config\n /config key sk-ant-... Set API key (current provider)\n /config key openai sk-... Set API key for provider\n /config provider openai Set provider\n /config endpoint https://api.example.com/v1 Set OpenAI-compatible base URL\n /config model claude-opus-4-20250514 Set model\n /config spinner set Reticulating splines, Organizing thoughts'
127
201
  });
128
202
  }
129
203
  }
@@ -12,6 +12,9 @@ import { modelCommand } from './model.js';
12
12
  import { permissionsCommand } from './permissions.js';
13
13
  import { skillsCommand } from './skills.js';
14
14
  import { retryCommand } from './retry.js';
15
+ import { inputModeCommand } from './input_mode.js';
16
+ import { keybindingsCommand } from './keybindings.js';
17
+ import { updateCommand } from './update.js';
15
18
  import { Command } from './types.js';
16
19
 
17
20
  const commandList: Command[] = [];
@@ -32,6 +35,9 @@ commandList.push(
32
35
  modelCommand,
33
36
  permissionsCommand,
34
37
  skillsCommand,
38
+ keybindingsCommand,
39
+ updateCommand,
40
+ inputModeCommand,
35
41
  retryCommand,
36
42
  exitCommand
37
43
  );
@@ -0,0 +1,22 @@
1
+ import { Command } from './types.js';
2
+
3
+ export const inputModeCommand: Command = {
4
+ name: 'inputmode',
5
+ description: 'Set input mode while an agent is running',
6
+ usage: '<queue|interrupt>',
7
+ handler: (args, ctx) => {
8
+ const mode = (args[0] || '').toLowerCase();
9
+ if (mode !== 'queue' && mode !== 'interrupt') {
10
+ ctx.addMessage({
11
+ role: 'system',
12
+ content: `Current input mode: ${ctx.getInputMode()}\n\nUsage: /inputmode <queue|interrupt>`
13
+ });
14
+ return;
15
+ }
16
+ ctx.setInputMode(mode);
17
+ ctx.addMessage({
18
+ role: 'system',
19
+ content: `✓ Input mode set: ${mode}`
20
+ });
21
+ }
22
+ };
@@ -0,0 +1,40 @@
1
+ import { Command } from './types.js';
2
+
3
+ const lines = [
4
+ 'Keybindings (readline-style):',
5
+ '',
6
+ 'Movement:',
7
+ ' Ctrl+A start of line',
8
+ ' Ctrl+E end of line',
9
+ ' Ctrl+B move left',
10
+ ' Ctrl+F move right',
11
+ ' Alt+B word left',
12
+ ' Alt+F word right',
13
+ '',
14
+ 'Editing:',
15
+ ' Ctrl+U kill to start',
16
+ ' Ctrl+K kill to end',
17
+ ' Ctrl+W kill previous word',
18
+ ' Alt+D kill next word',
19
+ ' Ctrl+D delete forward',
20
+ ' Ctrl+Y yank',
21
+ ' Alt+Y yank-pop',
22
+ ' Ctrl+T transpose chars',
23
+ ' Alt+T transpose words',
24
+ '',
25
+ 'History:',
26
+ ' Ctrl+P previous',
27
+ ' Ctrl+N next',
28
+ ' Up/Down arrows also work',
29
+ '',
30
+ 'Input mode:',
31
+ ' /inputmode queue|interrupt'
32
+ ];
33
+
34
+ export const keybindingsCommand: Command = {
35
+ name: 'keybindings',
36
+ description: 'Show keybindings',
37
+ handler: (_args, ctx) => {
38
+ ctx.addMessage({ role: 'system', content: lines.join('\n') });
39
+ }
40
+ };
@@ -68,6 +68,8 @@ export interface CommandContext {
68
68
  clipboard: ClipboardController;
69
69
  models: ModelsController;
70
70
  skills: SkillsController;
71
+ getInputMode: () => 'queue' | 'interrupt';
72
+ setInputMode: (mode: 'queue' | 'interrupt') => void;
71
73
  }
72
74
 
73
75
  export interface Command {