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
@@ -1,9 +1,14 @@
1
1
  import { readFile, writeFile, readdir, stat } from 'fs/promises';
2
2
  import { dirname, resolve } from 'path';
3
- import { Tool } from './types.js';
3
+ import { Tool, ToolContext } from './types.js';
4
4
  import { ToolCapability } from '../runtime/capabilities.js';
5
5
  import { buildSimpleDiff } from '../../utils/diff.js';
6
6
 
7
+ function resolvePath(path: string, context?: ToolContext): string {
8
+ const base = context?.cwd || process.cwd();
9
+ return resolve(base, path);
10
+ }
11
+
7
12
  // --- File Tools ---
8
13
 
9
14
  export const readFileTool: Tool = {
@@ -27,10 +32,10 @@ export const readFileTool: Tool = {
27
32
  required: ['path']
28
33
  }
29
34
  },
30
- execute: async (args) => {
31
- const path = resolve(String(args.path));
35
+ execute: async (args, context) => {
36
+ const path = resolvePath(String(args.path), context);
32
37
  const encoding = (args.encoding as BufferEncoding) || 'utf-8';
33
-
38
+
34
39
  try {
35
40
  const content = await readFile(path, { encoding });
36
41
  const stats = await stat(path);
@@ -70,16 +75,16 @@ export const writeFileTool: Tool = {
70
75
  required: ['path', 'content']
71
76
  }
72
77
  },
73
- execute: async (args) => {
74
- const path = resolve(String(args.path));
78
+ execute: async (args, context) => {
79
+ const path = resolvePath(String(args.path), context);
75
80
  const content = String(args.content);
76
81
  const append = args.append === 'true';
77
-
82
+
78
83
  try {
79
84
  // Ensure directory exists
80
85
  const { mkdir, appendFile } = await import('fs/promises');
81
86
  await mkdir(dirname(path), { recursive: true });
82
-
87
+
83
88
  let beforeContent = '';
84
89
  try {
85
90
  beforeContent = await readFile(path, { encoding: 'utf-8' });
@@ -92,7 +97,7 @@ export const writeFileTool: Tool = {
92
97
  } else {
93
98
  await writeFile(path, content, 'utf-8');
94
99
  }
95
-
100
+
96
101
  const stats = await stat(path);
97
102
  const afterContent = await readFile(path, { encoding: 'utf-8' });
98
103
  const diff = buildSimpleDiff(beforeContent, afterContent);
@@ -131,38 +136,38 @@ export const listDirectoryTool: Tool = {
131
136
  required: ['path']
132
137
  }
133
138
  },
134
- execute: async (args) => {
135
- const path = resolve(String(args.path));
139
+ execute: async (args, context) => {
140
+ const path = resolvePath(String(args.path), context);
136
141
  const recursive = args.recursive === 'true';
137
-
142
+
138
143
  async function listDir(dir: string, depth = 0): Promise<object[]> {
139
144
  if (depth > 3) return [];
140
-
145
+
141
146
  const entries = await readdir(dir, { withFileTypes: true });
142
147
  const results: object[] = [];
143
-
148
+
144
149
  for (const entry of entries.slice(0, 100)) { // Limit entries
145
150
  const entryPath = resolve(dir, entry.name);
146
151
  const info: Record<string, unknown> = {
147
152
  name: entry.name,
148
153
  type: entry.isDirectory() ? 'directory' : 'file'
149
154
  };
150
-
155
+
151
156
  if (entry.isFile()) {
152
157
  const s = await stat(entryPath);
153
158
  info.size = s.size;
154
159
  }
155
-
160
+
156
161
  if (recursive && entry.isDirectory() && !entry.name.startsWith('.')) {
157
162
  info.children = await listDir(entryPath, depth + 1);
158
163
  }
159
-
164
+
160
165
  results.push(info);
161
166
  }
162
-
167
+
163
168
  return results;
164
169
  }
165
-
170
+
166
171
  try {
167
172
  const entries = await listDir(path);
168
173
  return JSON.stringify({ path, entries });
@@ -1,4 +1,4 @@
1
- import { Tool } from './types.js';
1
+ import { Tool, ToolContext } from './types.js';
2
2
  import { ToolDefinition } from '../../types.js';
3
3
  import { readFileTool, writeFileTool, listDirectoryTool } from './file.js';
4
4
  import { runCommandTool } from './shell.js';
@@ -27,15 +27,16 @@ export function getTool(name: string, tools: Tool[] = defaultTools): Tool | unde
27
27
  }
28
28
 
29
29
  export async function executeTool(
30
- name: string,
30
+ name: string,
31
31
  args: Record<string, unknown>,
32
- tools: Tool[] = defaultTools
32
+ tools: Tool[] = defaultTools,
33
+ context?: ToolContext
33
34
  ): Promise<string> {
34
35
  const tool = getTool(name, tools);
35
36
  if (!tool) {
36
37
  throw new Error(`Unknown tool: ${name}`);
37
38
  }
38
- return tool.execute(args);
39
+ return tool.execute(args, context);
39
40
  }
40
41
 
41
42
  export { readFileTool, writeFileTool, listDirectoryTool } from './file.js';
@@ -1,6 +1,6 @@
1
1
  import { spawn } from 'child_process';
2
2
  import { resolve } from 'path';
3
- import { Tool } from './types.js';
3
+ import { Tool, ToolContext } from './types.js';
4
4
  import { ToolCapability } from '../runtime/capabilities.js';
5
5
 
6
6
  interface RunResult {
@@ -58,9 +58,10 @@ export const searchTool: Tool = {
58
58
  required: ['pattern']
59
59
  }
60
60
  },
61
- execute: async (args) => {
61
+ execute: async (args, context) => {
62
+ const baseCwd = context?.cwd || process.cwd();
62
63
  const pattern = String(args.pattern || '');
63
- const path = resolve(String(args.path || '.'));
64
+ const path = resolve(baseCwd, String(args.path || '.'));
64
65
  const glob = args.glob ? String(args.glob) : undefined;
65
66
  const outputMode = args.output_mode === 'files' ? 'files' : 'content';
66
67
  const maxResults = Math.max(1, Math.min(200, parseInt(String(args.max_results || '20'), 10)));
@@ -82,14 +83,14 @@ export const searchTool: Tool = {
82
83
 
83
84
  let result: RunResult | null = null;
84
85
  try {
85
- result = await runCommand('rg', rgArgs, process.cwd());
86
+ result = await runCommand('rg', rgArgs, baseCwd);
86
87
  } catch {
87
88
  result = null;
88
89
  }
89
90
 
90
91
  if (!result || (result.code !== 0 && result.code !== 1)) {
91
92
  const grepArgs = ['-R', '-n', pattern, path];
92
- const fallback = await runCommand('grep', grepArgs, process.cwd());
93
+ const fallback = await runCommand('grep', grepArgs, baseCwd);
93
94
  if (fallback.code !== 0 && fallback.code !== 1) {
94
95
  throw new Error(fallback.stderr || 'Search failed');
95
96
  }
@@ -1,7 +1,7 @@
1
1
  import { exec } from 'child_process';
2
2
  import { promisify } from 'util';
3
3
  import { resolve } from 'path';
4
- import { Tool } from './types.js';
4
+ import { Tool, ToolContext } from './types.js';
5
5
  import { ToolCapability } from '../runtime/capabilities.js';
6
6
 
7
7
  const execAsync = promisify(exec);
@@ -32,24 +32,25 @@ export const runCommandTool: Tool = {
32
32
  required: ['command']
33
33
  }
34
34
  },
35
- execute: async (args) => {
35
+ execute: async (args, context) => {
36
36
  const command = String(args.command);
37
- const cwd = args.cwd ? resolve(String(args.cwd)) : process.cwd();
37
+ const baseCwd = context?.cwd || process.cwd();
38
+ const cwd = args.cwd ? resolve(baseCwd, String(args.cwd)) : baseCwd;
38
39
  const timeout = parseInt(String(args.timeout || '30000'), 10);
39
-
40
+
40
41
  // Basic safety checks
41
42
  const dangerous = ['rm -rf /', 'mkfs', ':(){:|:&};:'];
42
43
  if (dangerous.some(d => command.includes(d))) {
43
44
  throw new Error('Command rejected for safety reasons');
44
45
  }
45
-
46
+
46
47
  try {
47
- const { stdout, stderr } = await execAsync(command, {
48
- cwd,
48
+ const { stdout, stderr } = await execAsync(command, {
49
+ cwd,
49
50
  timeout,
50
51
  maxBuffer: 1024 * 1024 // 1MB
51
52
  });
52
-
53
+
53
54
  return JSON.stringify({
54
55
  command,
55
56
  cwd,
@@ -59,7 +60,10 @@ export const runCommandTool: Tool = {
59
60
  });
60
61
  } catch (err) {
61
62
  const e = err as Error & { stdout?: string; stderr?: string; code?: number };
62
- throw new Error(`Command failed: ${e.message}\nstderr: ${e.stderr || ''}`);
63
+ // Include both stdout and stderr in error - many tools output errors to stdout
64
+ const output = [e.stdout, e.stderr].filter(Boolean).join('\n').trim();
65
+ const outputSnippet = output.slice(0, 2000) || '(no output)';
66
+ throw new Error(`Command failed (exit ${e.code || '?'}):\n${outputSnippet}`);
63
67
  }
64
68
  }
65
69
  };
@@ -3,8 +3,12 @@ import { ToolCapability } from '../runtime/capabilities.js';
3
3
 
4
4
  // --- Tool Interface ---
5
5
 
6
+ export interface ToolContext {
7
+ cwd: string; // Working directory for path resolution
8
+ }
9
+
6
10
  export interface Tool {
7
11
  definition: ToolDefinition;
8
12
  capabilities?: ToolCapability[];
9
- execute: (args: Record<string, unknown>) => Promise<string>;
13
+ execute: (args: Record<string, unknown>, context?: ToolContext) => Promise<string>;
10
14
  }
package/src/cli.tsx CHANGED
@@ -1,11 +1,11 @@
1
1
  #!/usr/bin/env node
2
2
  import React from 'react';
3
3
  import { render } from 'ink';
4
- import { resolve, dirname } from 'path';
5
- import { statSync, readFileSync } from 'fs';
6
- import { fileURLToPath } from 'url';
4
+ import { resolve } from 'path';
5
+ import { statSync } from 'fs';
7
6
  import { App } from './App.js';
8
7
  import { configStore } from './config.js';
8
+ import { getVersion } from './utils/version.js';
9
9
 
10
10
  // --- CLI Entry Point ---
11
11
 
@@ -76,15 +76,43 @@ async function main(): Promise<void> {
76
76
  await configStore.load(true);
77
77
 
78
78
  const isTty = Boolean(process.stdout.isTTY);
79
- const useAltScreen = isTty && process.env.ZTC_ALT_SCREEN !== '0' && process.env.ZTC_NO_ALT_SCREEN !== '1';
79
+ const useAltScreen = isTty
80
+ && process.env.ZTC_ALT_SCREEN === '1'
81
+ && process.env.ZTC_NO_ALT_SCREEN !== '1'
82
+ && process.env.ZTC_SCROLLBACK !== '1';
80
83
 
81
84
  if (useAltScreen) {
82
85
  process.stdout.write('\x1b[?1049h');
83
86
  process.stdout.write('\x1b[?25l');
87
+ }
88
+
89
+ if (isTty) {
90
+ // Clear screen and move cursor home for a clean start
84
91
  process.stdout.write('\x1b[2J');
85
92
  process.stdout.write('\x1b[H');
86
93
  }
87
94
 
95
+ // In scrollback mode, print a static header once at startup
96
+ const scrollback = !useAltScreen;
97
+ if (scrollback && isTty) {
98
+ const version = getVersion();
99
+ const dateLabel = new Date().toLocaleDateString('en-US', {
100
+ month: 'short',
101
+ day: '2-digit',
102
+ year: 'numeric'
103
+ });
104
+ const cols = process.stdout.columns || 80;
105
+ const title = 'Zerg Terminal Client';
106
+ const right = `${dateLabel} • v${version} • Ctrl+C exit • /help`;
107
+ const padding = Math.max(0, cols - title.length - right.length - 4);
108
+ const borderLine = '─'.repeat(cols - 2);
109
+
110
+ console.log(`┌${borderLine}┐`);
111
+ console.log(`│ ${title}${' '.repeat(padding)}${right} │`);
112
+ console.log(`└${borderLine}┘`);
113
+ console.log(''); // Extra line after header
114
+ }
115
+
88
116
  // Enable bracketed paste mode so we can detect paste boundaries
89
117
  process.stdout.write('\x1b[?2004h');
90
118
 
@@ -94,21 +122,23 @@ async function main(): Promise<void> {
94
122
  // Using flags=1 for basic disambiguation which reports Cmd+V as CSI sequence
95
123
  process.stdout.write('\x1b[>1u');
96
124
 
97
- // Wrap stdout.write to use synchronized output (reduces flicker)
98
- // DECSM/DECRM 2026 tells terminal to batch updates
99
- const originalWrite = process.stdout.write.bind(process.stdout);
100
- let syncPending = false;
101
- process.stdout.write = function(chunk: any, encoding?: any, callback?: any): boolean {
102
- if (!syncPending) {
103
- syncPending = true;
104
- originalWrite('\x1b[?2026h'); // Begin synchronized update
105
- setImmediate(() => {
106
- originalWrite('\x1b[?2026l'); // End synchronized update
107
- syncPending = false;
108
- });
109
- }
110
- return originalWrite(chunk, encoding, callback);
111
- } as typeof process.stdout.write;
125
+ if (useAltScreen) {
126
+ // Wrap stdout.write to use synchronized output (reduces flicker)
127
+ // DECSM/DECRM 2026 tells terminal to batch updates
128
+ const originalWrite = process.stdout.write.bind(process.stdout);
129
+ let syncPending = false;
130
+ process.stdout.write = function(chunk: any, encoding?: any, callback?: any): boolean {
131
+ if (!syncPending) {
132
+ syncPending = true;
133
+ originalWrite('\x1b[?2026h'); // Begin synchronized update
134
+ setImmediate(() => {
135
+ originalWrite('\x1b[?2026l'); // End synchronized update
136
+ syncPending = false;
137
+ });
138
+ }
139
+ return originalWrite(chunk, encoding, callback);
140
+ } as typeof process.stdout.write;
141
+ }
112
142
 
113
143
  // Render the app
114
144
  const { waitUntilExit } = render(<App />);
@@ -130,14 +160,4 @@ async function main(): Promise<void> {
130
160
 
131
161
  void main();
132
162
 
133
- function getVersion(): string {
134
- try {
135
- const here = dirname(fileURLToPath(import.meta.url));
136
- const pkgPath = resolve(here, '../package.json');
137
- const raw = readFileSync(pkgPath, 'utf-8');
138
- const parsed = JSON.parse(raw) as { version?: string };
139
- return parsed.version || '0.0.0';
140
- } catch {
141
- return '0.0.0';
142
- }
143
- }
163
+ // getVersion moved to utils/version.ts
@@ -0,0 +1,23 @@
1
+ import React from 'react';
2
+ import { AgentState } from '../types.js';
3
+ import { InkNode } from '../ui/ink/index.js';
4
+ import { buildActivityLineView } from '../ui/views/activity_line.js';
5
+
6
+ interface ActivityLineProps {
7
+ state: AgentState;
8
+ spinnerLabel?: string | null;
9
+ spinnerFrame?: string | null;
10
+ inputMode?: 'queue' | 'interrupt';
11
+ }
12
+
13
+ export const ActivityLine: React.FC<ActivityLineProps> = ({
14
+ state,
15
+ spinnerLabel,
16
+ spinnerFrame,
17
+ inputMode
18
+ }) => {
19
+ const node = buildActivityLineView({ state, spinnerLabel, spinnerFrame, inputMode });
20
+ return <InkNode node={node} />;
21
+ };
22
+
23
+ export default ActivityLine;
@@ -18,6 +18,7 @@ import { Box } from 'ink';
18
18
 
19
19
  interface FullScreenProps extends PropsWithChildren {
20
20
  debug?: boolean;
21
+ scrollback?: boolean;
21
22
  }
22
23
 
23
24
  function getTerminalSize() {
@@ -27,15 +28,15 @@ function getTerminalSize() {
27
28
  };
28
29
  }
29
30
 
30
- export const FullScreen: React.FC<FullScreenProps> = ({ children, debug = false }) => {
31
+ export const FullScreen: React.FC<FullScreenProps> = ({ children, debug = false, scrollback = false }) => {
31
32
  const { rows, columns } = useScreenSize();
32
33
 
33
34
  return (
34
35
  <Box
35
36
  flexDirection="column"
36
37
  width={columns}
37
- height={rows}
38
- overflow="hidden"
38
+ height={scrollback ? undefined : rows}
39
+ overflow={scrollback ? undefined : 'hidden'}
39
40
  borderStyle={debug ? 'single' : undefined}
40
41
  borderColor={debug ? 'red' : undefined}
41
42
  >
@@ -1,24 +1,70 @@
1
- import React from 'react';
1
+ import React, { useRef, useMemo } from 'react';
2
2
  import { Message } from '../types.js';
3
3
  import { InkNode } from '../ui/ink/index.js';
4
4
  import { buildMessageListView } from '../ui/views/message_list.js';
5
5
 
6
6
  interface MessageListProps {
7
7
  messages: Message[];
8
- height: number;
8
+ height?: number;
9
9
  maxMessages?: number;
10
10
  debug?: boolean;
11
11
  expandToolOutputs?: boolean;
12
+ scrollback?: boolean;
12
13
  }
13
14
 
14
- export const MessageList: React.FC<MessageListProps> = ({
15
- messages,
15
+ export const MessageList: React.FC<MessageListProps> = ({
16
+ messages,
16
17
  height,
17
18
  maxMessages = 50,
18
19
  debug = false,
19
- expandToolOutputs = false
20
+ expandToolOutputs = false,
21
+ scrollback = false
20
22
  }) => {
21
- const node = buildMessageListView({ messages, height, maxMessages, debug, expandToolOutputs });
23
+ // In scrollback mode, track which messages have been rendered to avoid duplication
24
+ const renderedIdsRef = useRef<Set<string>>(new Set());
25
+
26
+ // Filter to only new, complete messages in scrollback mode
27
+ const messagesToRender = useMemo(() => {
28
+ if (!scrollback) {
29
+ return messages;
30
+ }
31
+
32
+ // In scrollback mode, only render messages that:
33
+ // 1. Are NOT currently streaming (wait for completion)
34
+ // 2. Haven't been rendered yet
35
+ const newMessages: Message[] = [];
36
+ for (const msg of messages) {
37
+ // Skip streaming messages - we'll render them when complete
38
+ if (msg.isStreaming) {
39
+ continue;
40
+ }
41
+
42
+ // Skip already rendered messages
43
+ if (renderedIdsRef.current.has(msg.id)) {
44
+ continue;
45
+ }
46
+
47
+ // This is a new, complete message - render it
48
+ newMessages.push(msg);
49
+ // Mark as rendered immediately
50
+ renderedIdsRef.current.add(msg.id);
51
+ }
52
+ return newMessages;
53
+ }, [messages, scrollback]);
54
+
55
+ // Don't render anything if there are no new messages in scrollback mode
56
+ if (scrollback && messagesToRender.length === 0) {
57
+ return null;
58
+ }
59
+
60
+ const node = buildMessageListView({
61
+ messages: messagesToRender,
62
+ height,
63
+ maxMessages,
64
+ debug,
65
+ expandToolOutputs,
66
+ scrollback
67
+ });
22
68
  return <InkNode node={node} />;
23
69
  };
24
70
 
@@ -0,0 +1,59 @@
1
+ import React from 'react';
2
+ import { Box, Text } from 'ink';
3
+ import { Message } from '../types.js';
4
+
5
+ interface SingleMessageProps {
6
+ message: Message;
7
+ expandToolOutputs?: boolean;
8
+ }
9
+
10
+ interface RoleConfig {
11
+ icon: string;
12
+ color: string;
13
+ label: string;
14
+ }
15
+
16
+ const roleConfigs: Record<string, RoleConfig> = {
17
+ user: { icon: '❯', color: 'blue', label: 'You' },
18
+ assistant: { icon: '◆', color: 'magenta', label: 'Zerg' },
19
+ tool: { icon: '⏺', color: 'yellow', label: 'Trace' },
20
+ system: { icon: '●', color: 'gray', label: 'System' }
21
+ };
22
+
23
+ function formatTime(date: Date): string {
24
+ return date.toLocaleTimeString('en-US', {
25
+ hour: '2-digit',
26
+ minute: '2-digit',
27
+ hour12: false
28
+ });
29
+ }
30
+
31
+ export const SingleMessage: React.FC<SingleMessageProps> = ({
32
+ message,
33
+ expandToolOutputs = false
34
+ }) => {
35
+ const config = roleConfigs[message.role] || roleConfigs.system;
36
+ const toolOutput = message.metadata && (message.metadata as Record<string, any>).toolOutput;
37
+ const content = toolOutput && expandToolOutputs && toolOutput.full
38
+ ? toolOutput.full
39
+ : toolOutput && toolOutput.preview
40
+ ? toolOutput.preview
41
+ : message.content;
42
+
43
+ return (
44
+ <Box flexDirection="column" marginY={1} paddingX={1}>
45
+ <Box flexDirection="row">
46
+ <Text color={config.color} bold>{config.icon} {config.label}</Text>
47
+ <Text color="gray" dimColor> • {formatTime(message.timestamp)}</Text>
48
+ {message.isStreaming && <Text color="yellow" bold> ▌</Text>}
49
+ </Box>
50
+ <Box flexDirection="column" marginLeft={2}>
51
+ {content.split('\n').map((line: string, i: number) => (
52
+ <Text key={i}>{line}</Text>
53
+ ))}
54
+ </Box>
55
+ </Box>
56
+ );
57
+ };
58
+
59
+ export default SingleMessage;
@@ -15,6 +15,7 @@ interface StatusBarProps {
15
15
  emulationId?: string;
16
16
  inputMode?: 'queue' | 'interrupt';
17
17
  toast?: string | null;
18
+ spinnerLabel?: string | null;
18
19
  debug?: boolean;
19
20
  }
20
21
 
@@ -30,6 +31,7 @@ export const StatusBar: React.FC<StatusBarProps> = ({
30
31
  emulationId,
31
32
  inputMode,
32
33
  toast,
34
+ spinnerLabel,
33
35
  debug = false
34
36
  }) => {
35
37
  const node = buildStatusBarView({
@@ -44,6 +46,7 @@ export const StatusBar: React.FC<StatusBarProps> = ({
44
46
  emulationId,
45
47
  inputMode,
46
48
  toast,
49
+ spinnerLabel,
47
50
  debug
48
51
  });
49
52
  return <InkNode node={node} />;
@@ -1,6 +1,8 @@
1
1
  // Component exports
2
2
  export { Header } from './Header.js';
3
3
  export { MessageList } from './MessageList.js';
4
+ export { SingleMessage } from './SingleMessage.js';
4
5
  export { InputArea } from './InputArea.js';
5
6
  export { StatusBar } from './StatusBar.js';
6
- export { FullScreen, useScreenSize } from './FullScreen.js';
7
+ export { FullScreen, useScreenSize } from './FullScreen.js';
8
+ export { ActivityLine } from './ActivityLine.js';
@@ -7,5 +7,6 @@ export interface ZTCConfig {
7
7
  maxTokens: number;
8
8
  zergEndpoint?: string;
9
9
  emulationId?: string;
10
+ spinnerVerbs?: string[];
10
11
  toolPermissions?: Partial<Record<'file_read' | 'file_write' | 'shell_exec' | 'network', boolean>>;
11
12
  }
package/src/config.ts CHANGED
@@ -3,6 +3,7 @@ import { join } from 'path';
3
3
  import { readFile, writeFile, mkdir } from 'fs/promises';
4
4
 
5
5
  import { ZTCConfig } from './config/types.js';
6
+ import { DEFAULT_SPINNER_VERBS } from './utils/spinner_verbs.js';
6
7
 
7
8
  // --- Configuration Store ---
8
9
 
@@ -15,6 +16,7 @@ const DEFAULT_CONFIG: ZTCConfig = {
15
16
  maxTokens: 4096,
16
17
  zergEndpoint: undefined,
17
18
  emulationId: undefined,
19
+ spinnerVerbs: DEFAULT_SPINNER_VERBS,
18
20
  toolPermissions: {
19
21
  file_read: true,
20
22
  file_write: true,
@@ -46,6 +48,9 @@ class ConfigStore {
46
48
  if (this.config.model === 'claude-opus-4-5-20250514') {
47
49
  this.config.model = 'claude-opus-4-20250514';
48
50
  }
51
+ if (!this.config.spinnerVerbs) {
52
+ this.config.spinnerVerbs = DEFAULT_SPINNER_VERBS;
53
+ }
49
54
  if (!this.config.toolPermissions) {
50
55
  this.config.toolPermissions = { ...DEFAULT_CONFIG.toolPermissions };
51
56
  }
@@ -92,6 +97,9 @@ class ConfigStore {
92
97
  if (this.config.model === 'claude-opus-4-5-20250514') {
93
98
  this.config.model = 'claude-opus-4-20250514';
94
99
  }
100
+ if (!this.config.spinnerVerbs) {
101
+ this.config.spinnerVerbs = DEFAULT_SPINNER_VERBS;
102
+ }
95
103
  if (!this.config.toolPermissions) {
96
104
  this.config.toolPermissions = { ...DEFAULT_CONFIG.toolPermissions };
97
105
  }
@@ -0,0 +1,33 @@
1
+ import { AgentState } from '../../types.js';
2
+ import { box, text, LayoutNode } from '../core/index.js';
3
+
4
+ interface ActivityLineProps {
5
+ state: AgentState;
6
+ spinnerLabel?: string | null;
7
+ spinnerFrame?: string | null;
8
+ inputMode?: 'queue' | 'interrupt';
9
+ }
10
+
11
+ export function buildActivityLineView({
12
+ state,
13
+ spinnerLabel,
14
+ spinnerFrame,
15
+ inputMode
16
+ }: ActivityLineProps): LayoutNode {
17
+ const active = state.status === 'thinking' || state.status === 'streaming';
18
+ if (!active || !spinnerLabel) {
19
+ return box([], { height: 0 });
20
+ }
21
+ const frame = spinnerFrame || '✽';
22
+ const hint = inputMode === 'interrupt' ? ' (esc to interrupt)' : '';
23
+ return box([
24
+ text(`${frame} ${spinnerLabel}…${hint}`, { color: 'yellow' })
25
+ ], {
26
+ flexDirection: 'row',
27
+ paddingX: 2,
28
+ paddingY: 0,
29
+ marginTop: 1,
30
+ marginBottom: 1,
31
+ height: 1
32
+ });
33
+ }