zerg-ztc 0.1.4 → 0.1.6

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 (139) hide show
  1. package/dist/App.d.ts.map +1 -1
  2. package/dist/App.js +141 -16
  3. package/dist/App.js.map +1 -1
  4. package/dist/agent/agent.d.ts +4 -0
  5. package/dist/agent/agent.d.ts.map +1 -1
  6. package/dist/agent/agent.js +21 -3
  7. package/dist/agent/agent.js.map +1 -1
  8. package/dist/agent/commands/config.d.ts.map +1 -1
  9. package/dist/agent/commands/config.js +68 -2
  10. package/dist/agent/commands/config.js.map +1 -1
  11. package/dist/agent/commands/index.d.ts.map +1 -1
  12. package/dist/agent/commands/index.js +2 -1
  13. package/dist/agent/commands/index.js.map +1 -1
  14. package/dist/agent/commands/update.d.ts +3 -0
  15. package/dist/agent/commands/update.d.ts.map +1 -0
  16. package/dist/agent/commands/update.js +33 -0
  17. package/dist/agent/commands/update.js.map +1 -0
  18. package/dist/agent/tools/file.d.ts.map +1 -1
  19. package/dist/agent/tools/file.js +10 -6
  20. package/dist/agent/tools/file.js.map +1 -1
  21. package/dist/agent/tools/index.d.ts +2 -2
  22. package/dist/agent/tools/index.d.ts.map +1 -1
  23. package/dist/agent/tools/index.js +2 -2
  24. package/dist/agent/tools/index.js.map +1 -1
  25. package/dist/agent/tools/search.d.ts.map +1 -1
  26. package/dist/agent/tools/search.js +5 -4
  27. package/dist/agent/tools/search.js.map +1 -1
  28. package/dist/agent/tools/shell.d.ts.map +1 -1
  29. package/dist/agent/tools/shell.js +7 -3
  30. package/dist/agent/tools/shell.js.map +1 -1
  31. package/dist/agent/tools/types.d.ts +4 -1
  32. package/dist/agent/tools/types.d.ts.map +1 -1
  33. package/dist/cli.js +46 -31
  34. package/dist/cli.js.map +1 -1
  35. package/dist/components/ActivityLine.d.ts +11 -0
  36. package/dist/components/ActivityLine.d.ts.map +1 -0
  37. package/dist/components/ActivityLine.js +9 -0
  38. package/dist/components/ActivityLine.js.map +1 -0
  39. package/dist/components/FullScreen.d.ts +1 -0
  40. package/dist/components/FullScreen.d.ts.map +1 -1
  41. package/dist/components/FullScreen.js +2 -2
  42. package/dist/components/FullScreen.js.map +1 -1
  43. package/dist/components/MessageList.d.ts +2 -1
  44. package/dist/components/MessageList.d.ts.map +1 -1
  45. package/dist/components/MessageList.js +41 -2
  46. package/dist/components/MessageList.js.map +1 -1
  47. package/dist/components/SingleMessage.d.ts +9 -0
  48. package/dist/components/SingleMessage.d.ts.map +1 -0
  49. package/dist/components/SingleMessage.js +27 -0
  50. package/dist/components/SingleMessage.js.map +1 -0
  51. package/dist/components/StatusBar.d.ts +1 -0
  52. package/dist/components/StatusBar.d.ts.map +1 -1
  53. package/dist/components/StatusBar.js +2 -1
  54. package/dist/components/StatusBar.js.map +1 -1
  55. package/dist/components/index.d.ts +2 -0
  56. package/dist/components/index.d.ts.map +1 -1
  57. package/dist/components/index.js +2 -0
  58. package/dist/components/index.js.map +1 -1
  59. package/dist/config/types.d.ts +1 -0
  60. package/dist/config/types.d.ts.map +1 -1
  61. package/dist/config.d.ts.map +1 -1
  62. package/dist/config.js +8 -0
  63. package/dist/config.js.map +1 -1
  64. package/dist/ui/views/activity_line.d.ts +11 -0
  65. package/dist/ui/views/activity_line.d.ts.map +1 -0
  66. package/dist/ui/views/activity_line.js +20 -0
  67. package/dist/ui/views/activity_line.js.map +1 -0
  68. package/dist/ui/views/app.d.ts +5 -2
  69. package/dist/ui/views/app.d.ts.map +1 -1
  70. package/dist/ui/views/app.js +17 -14
  71. package/dist/ui/views/app.js.map +1 -1
  72. package/dist/ui/views/header.d.ts.map +1 -1
  73. package/dist/ui/views/header.js +3 -4
  74. package/dist/ui/views/header.js.map +1 -1
  75. package/dist/ui/views/input_area.d.ts.map +1 -1
  76. package/dist/ui/views/input_area.js +25 -12
  77. package/dist/ui/views/input_area.js.map +1 -1
  78. package/dist/ui/views/message_list.d.ts +3 -2
  79. package/dist/ui/views/message_list.d.ts.map +1 -1
  80. package/dist/ui/views/message_list.js +33 -19
  81. package/dist/ui/views/message_list.js.map +1 -1
  82. package/dist/ui/views/status_bar.d.ts +2 -1
  83. package/dist/ui/views/status_bar.d.ts.map +1 -1
  84. package/dist/ui/views/status_bar.js +4 -2
  85. package/dist/ui/views/status_bar.js.map +1 -1
  86. package/dist/utils/shell.d.ts.map +1 -1
  87. package/dist/utils/shell.js +9 -1
  88. package/dist/utils/shell.js.map +1 -1
  89. package/dist/utils/spinner_frames.d.ts +2 -0
  90. package/dist/utils/spinner_frames.d.ts.map +1 -0
  91. package/dist/utils/spinner_frames.js +2 -0
  92. package/dist/utils/spinner_frames.js.map +1 -0
  93. package/dist/utils/spinner_verbs.d.ts +4 -0
  94. package/dist/utils/spinner_verbs.d.ts.map +1 -0
  95. package/dist/utils/spinner_verbs.js +22 -0
  96. package/dist/utils/spinner_verbs.js.map +1 -0
  97. package/dist/utils/tool_trace.d.ts.map +1 -1
  98. package/dist/utils/tool_trace.js +12 -2
  99. package/dist/utils/tool_trace.js.map +1 -1
  100. package/dist/utils/update.d.ts +9 -0
  101. package/dist/utils/update.d.ts.map +1 -0
  102. package/dist/utils/update.js +37 -0
  103. package/dist/utils/update.js.map +1 -0
  104. package/dist/utils/version.d.ts +2 -0
  105. package/dist/utils/version.d.ts.map +1 -0
  106. package/dist/utils/version.js +16 -0
  107. package/dist/utils/version.js.map +1 -0
  108. package/package.json +1 -1
  109. package/src/App.tsx +180 -26
  110. package/src/agent/agent.ts +26 -6
  111. package/src/agent/commands/config.ts +76 -2
  112. package/src/agent/commands/index.ts +2 -0
  113. package/src/agent/commands/update.ts +32 -0
  114. package/src/agent/tools/file.ts +24 -19
  115. package/src/agent/tools/index.ts +5 -4
  116. package/src/agent/tools/search.ts +6 -5
  117. package/src/agent/tools/shell.ts +13 -9
  118. package/src/agent/tools/types.ts +5 -1
  119. package/src/cli.tsx +50 -30
  120. package/src/components/ActivityLine.tsx +23 -0
  121. package/src/components/FullScreen.tsx +4 -3
  122. package/src/components/MessageList.tsx +52 -6
  123. package/src/components/SingleMessage.tsx +59 -0
  124. package/src/components/StatusBar.tsx +3 -0
  125. package/src/components/index.tsx +3 -1
  126. package/src/config/types.ts +1 -0
  127. package/src/config.ts +8 -0
  128. package/src/ui/views/activity_line.ts +33 -0
  129. package/src/ui/views/app.ts +23 -14
  130. package/src/ui/views/header.ts +3 -4
  131. package/src/ui/views/input_area.ts +28 -17
  132. package/src/ui/views/message_list.ts +36 -20
  133. package/src/ui/views/status_bar.ts +5 -1
  134. package/src/utils/shell.ts +10 -1
  135. package/src/utils/spinner_frames.ts +1 -0
  136. package/src/utils/spinner_verbs.ts +23 -0
  137. package/src/utils/tool_trace.ts +12 -2
  138. package/src/utils/update.ts +44 -0
  139. 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
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
 
@@ -78,6 +83,7 @@ export const App: React.FC = () => {
78
83
  const queueRef = useRef<string[]>([]);
79
84
  const activeRunIdRef = useRef<string | null>(null);
80
85
  const runCounterRef = useRef(0);
86
+ const lastRunDurationRef = useRef<number | null>(null);
81
87
 
82
88
  React.useEffect(() => {
83
89
  let active = true;
@@ -106,6 +112,8 @@ export const App: React.FC = () => {
106
112
  const [retryAvailable, setRetryAvailable] = useState(false);
107
113
  const [toast, setToast] = useState<string | null>(null);
108
114
  const toastTimerRef = useRef<NodeJS.Timeout | null>(null);
115
+ const [spinnerLabel, setSpinnerLabel] = useState<string | null>(null);
116
+ const [spinnerFrame, setSpinnerFrame] = useState<string | null>(null);
109
117
  const streamingMessageId = React.useRef<string | null>(null);
110
118
  const streamedResponse = React.useRef(false);
111
119
  const toolStartTimes = React.useRef<Map<string, number[]>>(new Map());
@@ -117,6 +125,8 @@ export const App: React.FC = () => {
117
125
  );
118
126
  const contextLength = agentState.contextTokens ?? fallbackContextLength;
119
127
  const contextEstimated = agentState.contextTokens ? agentState.tokensEstimated : true;
128
+ const spinnerVerbs = configStore.get().spinnerVerbs || DEFAULT_SPINNER_VERBS;
129
+ const spinnerVerbsKey = spinnerVerbs.join('|');
120
130
 
121
131
  React.useEffect(() => {
122
132
  renderCount.current += 1;
@@ -126,15 +136,69 @@ export const App: React.FC = () => {
126
136
  messagesRef.current = messages;
127
137
  }, [messages]);
128
138
  const [debug, setDebug] = useState(false);
139
+ const scrollback = process.env.ZTC_SCROLLBACK === '1' || process.env.ZTC_ALT_SCREEN !== '1';
129
140
 
130
- const inputHeight = 5; // 1 input line + 4 suggestion lines
131
- const headerHeight = 1;
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]);
155
+
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)
132
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]);
133
197
 
134
198
  // Calculate content height (total - header - input - status)
135
199
  const contentHeight = useMemo(
136
- () => Math.max(rows - (headerHeight + inputHeight + statusHeight), 5),
137
- [rows]
200
+ () => scrollback ? undefined : Math.max(rows - (headerHeight + inputHeight + statusHeight), 5),
201
+ [rows, scrollback, inputHeight]
138
202
  );
139
203
 
140
204
  // Reload agent when config changes
@@ -180,6 +244,21 @@ export const App: React.FC = () => {
180
244
  }]);
181
245
  }, []);
182
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
+
183
262
  const clearMessages = useCallback(() => {
184
263
  setMessages([]);
185
264
  }, []);
@@ -191,10 +270,14 @@ export const App: React.FC = () => {
191
270
  setCwd: async (path: string) => {
192
271
  const next = await resolveWorkingDir(shellCwdRef.current, path);
193
272
  shellCwdRef.current = next;
273
+ // Also update agent's working directory so tools use it
274
+ if (agent) {
275
+ agent.setCwd(next);
276
+ }
194
277
  return next;
195
278
  },
196
279
  run: async (command: string) => runShellCommand(command, shellCwdRef.current)
197
- }), []);
280
+ }), [agent]);
198
281
 
199
282
  const isRetryableError = useCallback((message: string) => {
200
283
  const lower = message.toLowerCase();
@@ -261,6 +344,7 @@ export const App: React.FC = () => {
261
344
  const runId = `${Date.now()}_${runCounterRef.current++}`;
262
345
  activeRunIdRef.current = runId;
263
346
  const isActive = () => activeRunIdRef.current === runId;
347
+ const runStartedAt = Date.now();
264
348
  setAgentState({ status: 'thinking', startedAt: new Date() });
265
349
  void runWithRetry(last.messages, last.agent, true, isActive).catch((err) => {
266
350
  const message = (err as Error).message || 'Agent error';
@@ -331,7 +415,13 @@ export const App: React.FC = () => {
331
415
  if (text.startsWith('!')) {
332
416
  const commandText = text.slice(1).trim();
333
417
  if (commandText) {
334
- handleCommand('shell', [commandText]);
418
+ // Intercept 'cd' commands to use the proper /cd handler that persists
419
+ if (commandText === 'cd' || commandText.startsWith('cd ')) {
420
+ const path = commandText.slice(2).trim();
421
+ handleCommand('cd', path ? [path] : []);
422
+ } else {
423
+ handleCommand('shell', [commandText]);
424
+ }
335
425
  }
336
426
  return;
337
427
  }
@@ -417,6 +507,7 @@ export const App: React.FC = () => {
417
507
  const runId = `${Date.now()}_${runCounterRef.current++}`;
418
508
  activeRunIdRef.current = runId;
419
509
  const isActive = () => activeRunIdRef.current === runId;
510
+ const runStartedAt = Date.now();
420
511
  setAgentState({ status: 'thinking', startedAt: new Date() });
421
512
  streamingMessageId.current = null;
422
513
  streamedResponse.current = false;
@@ -466,10 +557,18 @@ export const App: React.FC = () => {
466
557
  break;
467
558
  case 'stream_end':
468
559
  if (streamingMessageId.current) {
469
- setMessages(prev => prev.map(msg => {
470
- if (msg.id !== streamingMessageId.current) return msg;
471
- return { ...msg, isStreaming: false };
472
- }));
560
+ setMessages(prev => {
561
+ // Remove the streaming message if it ended up empty (tool-only response)
562
+ const streamingMsg = prev.find(m => m.id === streamingMessageId.current);
563
+ if (streamingMsg && (!streamingMsg.content || streamingMsg.content.trim() === '')) {
564
+ return prev.filter(m => m.id !== streamingMessageId.current);
565
+ }
566
+ // Otherwise mark it as no longer streaming
567
+ return prev.map(msg => {
568
+ if (msg.id !== streamingMessageId.current) return msg;
569
+ return { ...msg, isStreaming: false };
570
+ });
571
+ });
473
572
  }
474
573
  streamingMessageId.current = null;
475
574
  break;
@@ -480,7 +579,7 @@ export const App: React.FC = () => {
480
579
  toolStartTimes.current.set(key, bucket);
481
580
  const duration = started ? Date.now() - started : undefined;
482
581
  const emulationId = configStore.getEmulationId();
483
- if (getTraceStyle(emulationId) === 'claude_code') {
582
+ if (getTraceStyle(emulationId) === 'claude_code' || getTraceStyle(emulationId) === 'codex') {
484
583
  const output = buildToolOutputMessage(event.tool, event.result, duration, emulationId);
485
584
  addMessage({
486
585
  role: 'tool',
@@ -553,6 +652,14 @@ export const App: React.FC = () => {
553
652
  streamedResponse.current = false;
554
653
  streamingMessageId.current = null;
555
654
  activeRunIdRef.current = null;
655
+ lastRunDurationRef.current = Date.now() - runStartedAt;
656
+ if (getTraceStyle(configStore.getEmulationId()) === 'codex') {
657
+ const seconds = Math.max(0, Math.round((lastRunDurationRef.current || 0) / 1000));
658
+ addMessage({
659
+ role: 'tool',
660
+ content: `✻ Worked for ${seconds}s`
661
+ });
662
+ }
556
663
  cleanup();
557
664
  if (inputMode === 'queue' && queueRef.current.length > 0) {
558
665
  const next = queueRef.current.shift();
@@ -577,6 +684,7 @@ export const App: React.FC = () => {
577
684
  rows,
578
685
  commands,
579
686
  hasApiKey: configStore.hasApiKey(),
687
+ version: getVersion(),
580
688
  contextLength,
581
689
  contextEstimated,
582
690
  provider,
@@ -584,10 +692,12 @@ export const App: React.FC = () => {
584
692
  emulationId,
585
693
  inputMode,
586
694
  toast,
695
+ spinnerLabel,
696
+ spinnerFrame,
587
697
  debug,
588
698
  expandToolOutputs
589
699
  });
590
- }, [mirrorEnabled, messages, agentState, inputSnapshot, sessionId, rows, contextLength, contextEstimated, provider, model, emulationId, inputMode, toast, debug, expandToolOutputs]);
700
+ }, [mirrorEnabled, messages, agentState, inputSnapshot, sessionId, rows, contextLength, contextEstimated, provider, model, emulationId, inputMode, toast, spinnerLabel, spinnerFrame, debug, expandToolOutputs]);
591
701
 
592
702
  const showToast = useCallback((message: string) => {
593
703
  setToast(message);
@@ -601,22 +711,66 @@ export const App: React.FC = () => {
601
711
 
602
712
  useMirror(layoutTree, inputBus);
603
713
 
714
+ // Compute messages for Static (completed) vs live (streaming)
715
+ // Static component handles deduplication by key - it only renders new items
716
+ const { staticMessages, streamingMessage } = useMemo(() => {
717
+ if (!scrollback) {
718
+ return { staticMessages: [], streamingMessage: null };
719
+ }
720
+
721
+ // Completed messages go to Static (rendered once, kept in scrollback)
722
+ const completed = messages.filter(m => !m.isStreaming);
723
+ // Streaming message stays in live section (can update)
724
+ const streaming = messages.find(m => m.isStreaming) || null;
725
+
726
+ return {
727
+ staticMessages: completed,
728
+ streamingMessage: streaming
729
+ };
730
+ }, [messages, scrollback]);
731
+
604
732
  return (
605
- <FullScreen debug={debug}>
606
- <Header version="0.1.0" debug={debug} />
607
-
608
- <MessageList
609
- messages={messages}
610
- height={contentHeight}
611
- debug={debug}
612
- expandToolOutputs={expandToolOutputs}
733
+ <FullScreen debug={debug} scrollback={scrollback}>
734
+ {!scrollback && <Header version={getVersion()} debug={debug} />}
735
+
736
+ {scrollback ? (
737
+ <Box flexDirection="column">
738
+ {/* Static: each message rendered ONCE, stays in scrollback buffer */}
739
+ <Static items={staticMessages}>
740
+ {(msg: Message) => (
741
+ <Box key={msg.id} flexDirection="column">
742
+ <SingleMessage message={msg} expandToolOutputs={expandToolOutputs} />
743
+ </Box>
744
+ )}
745
+ </Static>
746
+
747
+ {/* Live section: streaming message can update */}
748
+ {streamingMessage && (
749
+ <SingleMessage message={streamingMessage} expandToolOutputs={expandToolOutputs} />
750
+ )}
751
+ </Box>
752
+ ) : (
753
+ <MessageList
754
+ messages={messages}
755
+ height={contentHeight}
756
+ debug={debug}
757
+ expandToolOutputs={expandToolOutputs}
758
+ scrollback={scrollback}
759
+ />
760
+ )}
761
+
762
+ <ActivityLine
763
+ state={agentState}
764
+ spinnerLabel={spinnerLabel}
765
+ spinnerFrame={spinnerFrame}
766
+ inputMode={inputMode}
613
767
  />
614
-
768
+
615
769
  <InputArea
616
770
  onSubmit={handleSubmit}
617
771
  onCommand={handleCommand}
618
772
  commands={commands}
619
- onStateChange={mirrorEnabled ? setInputSnapshot : undefined}
773
+ onStateChange={setInputSnapshot}
620
774
  onToast={showToast}
621
775
  cols={columns}
622
776
  inputBus={inputBus}
@@ -630,11 +784,11 @@ export const App: React.FC = () => {
630
784
  'Type a message or /help for commands...'
631
785
  }
632
786
  />
633
-
787
+
634
788
  <StatusBar
635
789
  state={agentState}
636
790
  sessionId={sessionId}
637
- version="0.1.0"
791
+ version={getVersion()}
638
792
  connectionStatus={configStore.hasApiKey() ? 'connected' : 'disconnected'}
639
793
  contextLength={contextLength}
640
794
  contextEstimated={contextEstimated}
@@ -22,6 +22,7 @@ export interface AgentConfig {
22
22
  backend?: AgentBackend;
23
23
  policy?: Policy;
24
24
  tracer?: Tracer;
25
+ cwd?: string; // Working directory for tool execution
25
26
  }
26
27
 
27
28
  export class AgentError extends Error {
@@ -42,10 +43,11 @@ export class Agent {
42
43
  private policy: Policy;
43
44
  private tracer: Tracer;
44
45
  private streamChunkSize = 32;
45
-
46
+ private _cwd: string;
47
+
46
48
  constructor(config: AgentConfig = {}) {
47
49
  const apiKey = config.apiKey || process.env.ANTHROPIC_API_KEY || '';
48
-
50
+
49
51
  this.config = {
50
52
  model: config.model || 'claude-opus-4-20250514',
51
53
  apiKey,
@@ -56,18 +58,28 @@ export class Agent {
56
58
  systemPrompt: config.systemPrompt || this.getDefaultSystemPrompt(),
57
59
  backend: config.backend || new AnthropicBackend({ apiKey, apiEndpoint: config.apiEndpoint }),
58
60
  policy: config.policy || new AllowAllPolicy(),
59
- tracer: config.tracer || new NoopTracer()
61
+ tracer: config.tracer || new NoopTracer(),
62
+ cwd: config.cwd || process.cwd()
60
63
  };
61
64
 
62
65
  this.backend = this.config.backend;
63
66
  this.policy = this.config.policy;
64
67
  this.tracer = this.config.tracer;
68
+ this._cwd = this.config.cwd;
65
69
  }
66
70
 
67
71
  hasApiKey(): boolean {
68
72
  return !!this.config.apiKey && this.config.apiKey.length > 0;
69
73
  }
70
74
 
75
+ setCwd(cwd: string): void {
76
+ this._cwd = cwd;
77
+ }
78
+
79
+ getCwd(): string {
80
+ return this._cwd;
81
+ }
82
+
71
83
  private getDefaultSystemPrompt(): string {
72
84
  return `You are ZTC (Zerg Terminal Client), an AI assistant that helps users interact with the Zerg continual AI system and manage local development tasks.
73
85
 
@@ -97,7 +109,14 @@ When a user intent maps to an available slash command, invoke the command direct
97
109
  // Convert messages to API format
98
110
  private formatMessages(messages: Message[]): LlmMessage[] {
99
111
  return messages
100
- .filter((m): m is Message & { role: 'user' | 'assistant' } => m.role === 'user' || m.role === 'assistant')
112
+ .filter((m): m is Message & { role: 'user' | 'assistant' } => {
113
+ // Only include user and assistant messages
114
+ if (m.role !== 'user' && m.role !== 'assistant') return false;
115
+ // Filter out assistant messages with empty content (from tool-only responses)
116
+ // The API rejects empty content for non-final assistant messages
117
+ if (m.role === 'assistant' && (!m.content || m.content.trim() === '')) return false;
118
+ return true;
119
+ })
101
120
  .map(m => ({
102
121
  role: m.role,
103
122
  content: m.role === 'user' ? this.buildContentBlocks(m.content) : m.content
@@ -336,9 +355,10 @@ When a user intent maps to an available slash command, invoke the command direct
336
355
 
337
356
  try {
338
357
  const result = await executeTool(
339
- toolBlock.name,
358
+ toolBlock.name,
340
359
  toolBlock.input,
341
- this.config.tools
360
+ this.config.tools,
361
+ { cwd: this._cwd }
342
362
  );
343
363
 
344
364
  toolCall.status = 'complete';
@@ -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
  }
@@ -14,6 +14,7 @@ import { skillsCommand } from './skills.js';
14
14
  import { retryCommand } from './retry.js';
15
15
  import { inputModeCommand } from './input_mode.js';
16
16
  import { keybindingsCommand } from './keybindings.js';
17
+ import { updateCommand } from './update.js';
17
18
  import { Command } from './types.js';
18
19
 
19
20
  const commandList: Command[] = [];
@@ -35,6 +36,7 @@ commandList.push(
35
36
  permissionsCommand,
36
37
  skillsCommand,
37
38
  keybindingsCommand,
39
+ updateCommand,
38
40
  inputModeCommand,
39
41
  retryCommand,
40
42
  exitCommand
@@ -0,0 +1,32 @@
1
+ import { Command } from './types.js';
2
+ import { getVersion } from '../../utils/version.js';
3
+ import { checkForUpdate } from '../../utils/update.js';
4
+
5
+ export const updateCommand: Command = {
6
+ name: 'update',
7
+ description: 'Check for updates and install the latest version',
8
+ handler: async (_args, ctx) => {
9
+ try {
10
+ ctx.addMessage({ role: 'system', content: 'Checking for updates...' });
11
+ const current = getVersion();
12
+ const info = await checkForUpdate(current);
13
+ if (!info.hasUpdate) {
14
+ ctx.addMessage({ role: 'system', content: `ZTC is up to date (v${current}).` });
15
+ return;
16
+ }
17
+ ctx.addMessage({ role: 'system', content: `Updating to v${info.latest}...` });
18
+ const result = await ctx.shell.run('npm install -g zerg-ztc@latest');
19
+ if (result.exitCode === 0) {
20
+ ctx.addMessage({ role: 'system', content: `✓ Updated to v${info.latest}. Restart ZTC to use the new version.` });
21
+ } else {
22
+ ctx.addMessage({
23
+ role: 'system',
24
+ content: `Update failed (exit ${result.exitCode}).\n${result.stderr || result.stdout || ''}`.trim()
25
+ });
26
+ }
27
+ } catch (err) {
28
+ const message = err instanceof Error ? err.message : 'Update failed';
29
+ ctx.addMessage({ role: 'system', content: `Update failed: ${message}` });
30
+ }
31
+ }
32
+ };