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
@@ -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;
@@ -13,7 +13,9 @@ interface StatusBarProps {
13
13
  provider?: string;
14
14
  model?: string;
15
15
  emulationId?: string;
16
+ inputMode?: 'queue' | 'interrupt';
16
17
  toast?: string | null;
18
+ spinnerLabel?: string | null;
17
19
  debug?: boolean;
18
20
  }
19
21
 
@@ -27,7 +29,9 @@ export const StatusBar: React.FC<StatusBarProps> = ({
27
29
  provider,
28
30
  model,
29
31
  emulationId,
32
+ inputMode,
30
33
  toast,
34
+ spinnerLabel,
31
35
  debug = false
32
36
  }) => {
33
37
  const node = buildStatusBarView({
@@ -40,7 +44,9 @@ export const StatusBar: React.FC<StatusBarProps> = ({
40
44
  provider,
41
45
  model,
42
46
  emulationId,
47
+ inputMode,
43
48
  toast,
49
+ spinnerLabel,
44
50
  debug
45
51
  });
46
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
  }
package/src/types.ts CHANGED
@@ -7,6 +7,7 @@ export type MessageRole = 'user' | 'assistant' | 'tool' | 'system';
7
7
  export type ToolStatus = 'pending' | 'running' | 'complete' | 'error';
8
8
 
9
9
  export type AgentStatus = 'idle' | 'thinking' | 'tool_use' | 'streaming' | 'error';
10
+ export type InputMode = 'queue' | 'interrupt';
10
11
 
11
12
  export interface ToolCall {
12
13
  id: string;
@@ -36,20 +36,24 @@ export function clampCursor(segments: InputSegment[], cursor: InputCursor): Inpu
36
36
 
37
37
  export function insertText(state: InputState, text: string): InputState {
38
38
  if (text.length === 0) return state;
39
- const segments = [...state.segments];
39
+ // Deep copy segments to avoid mutating original state
40
+ const segments: InputSegment[] = state.segments.map(s => ({ ...s }));
40
41
  const cursor = clampCursor(segments, state.cursor);
41
42
 
42
43
  if (cursor.index === segments.length) {
43
44
  const last = segments[segments.length - 1];
44
45
  if (last && last.type === 'text') {
45
- last.text += text;
46
+ const newText = last.text + text;
47
+ segments[segments.length - 1] = { ...last, text: newText };
46
48
  } else {
47
49
  segments.push({ type: 'text', text });
48
50
  }
51
+ const finalSegment = segments[segments.length - 1];
52
+ const finalOffset = finalSegment.type === 'text' ? finalSegment.text.length : 1;
49
53
  return {
50
54
  ...state,
51
55
  segments: normalizeSegments(segments),
52
- cursor: { index: segments.length - 1, offset: (segments[segments.length - 1] as any).text.length }
56
+ cursor: { index: segments.length - 1, offset: finalOffset }
53
57
  };
54
58
  }
55
59
 
@@ -57,7 +61,8 @@ export function insertText(state: InputState, text: string): InputState {
57
61
  if (segment.type === 'text') {
58
62
  const before = segment.text.slice(0, cursor.offset);
59
63
  const after = segment.text.slice(cursor.offset);
60
- segment.text = before + text + after;
64
+ const newText = before + text + after;
65
+ segments[cursor.index] = { ...segment, text: newText };
61
66
  return {
62
67
  ...state,
63
68
  segments: normalizeSegments(segments),
@@ -94,18 +99,20 @@ export function insertBadge(state: InputState, segment: InputSegment): InputStat
94
99
  }
95
100
 
96
101
  export function backspace(state: InputState): InputState {
97
- const segments = [...state.segments];
102
+ // Deep copy segments to avoid mutating original state
103
+ const segments: InputSegment[] = state.segments.map(s => ({ ...s }));
98
104
  const cursor = clampCursor(segments, state.cursor);
99
105
 
100
106
  if (cursor.index === segments.length) {
101
107
  if (segments.length === 0) return state;
102
108
  const last = segments[segments.length - 1];
103
109
  if (last.type === 'text' && last.text.length > 0) {
104
- last.text = last.text.slice(0, -1);
110
+ const newText = last.text.slice(0, -1);
111
+ segments[segments.length - 1] = { ...last, text: newText };
105
112
  return {
106
113
  ...state,
107
114
  segments: normalizeSegments(segments),
108
- cursor: { index: segments.length - 1, offset: last.text.length }
115
+ cursor: { index: segments.length - 1, offset: newText.length }
109
116
  };
110
117
  }
111
118
  segments.pop();
@@ -119,7 +126,8 @@ export function backspace(state: InputState): InputState {
119
126
  const segment = segments[cursor.index];
120
127
  if (segment.type === 'text') {
121
128
  if (cursor.offset > 0) {
122
- segment.text = segment.text.slice(0, cursor.offset - 1) + segment.text.slice(cursor.offset);
129
+ const newText = segment.text.slice(0, cursor.offset - 1) + segment.text.slice(cursor.offset);
130
+ segments[cursor.index] = { ...segment, text: newText };
123
131
  return {
124
132
  ...state,
125
133
  segments: normalizeSegments(segments),
@@ -130,7 +138,7 @@ export function backspace(state: InputState): InputState {
130
138
  const prev = segments[cursor.index - 1];
131
139
  if (prev.type === 'text') {
132
140
  const prevLen = prev.text.length;
133
- prev.text += segment.text;
141
+ segments[cursor.index - 1] = { ...prev, text: prev.text + segment.text };
134
142
  segments.splice(cursor.index, 1);
135
143
  return {
136
144
  ...state,
@@ -159,7 +167,7 @@ export function backspace(state: InputState): InputState {
159
167
  const prev = segments[cursor.index - 1];
160
168
  if (prev.type === 'text') {
161
169
  const prevLen = prev.text.length;
162
- prev.text = prev.text.slice(0, -1);
170
+ segments[cursor.index - 1] = { ...prev, text: prev.text.slice(0, -1) };
163
171
  return {
164
172
  ...state,
165
173
  segments: normalizeSegments(segments),
@@ -175,7 +183,8 @@ export function backspace(state: InputState): InputState {
175
183
  }
176
184
 
177
185
  export function deleteForward(state: InputState): InputState {
178
- const segments = [...state.segments];
186
+ // Deep copy segments to avoid mutating original state
187
+ const segments: InputSegment[] = state.segments.map(s => ({ ...s }));
179
188
  const cursor = clampCursor(segments, state.cursor);
180
189
 
181
190
  if (cursor.index === segments.length) return state;
@@ -183,7 +192,8 @@ export function deleteForward(state: InputState): InputState {
183
192
  const segment = segments[cursor.index];
184
193
  if (segment.type === 'text') {
185
194
  if (cursor.offset < segment.text.length) {
186
- segment.text = segment.text.slice(0, cursor.offset) + segment.text.slice(cursor.offset + 1);
195
+ const newText = segment.text.slice(0, cursor.offset) + segment.text.slice(cursor.offset + 1);
196
+ segments[cursor.index] = { ...segment, text: newText };
187
197
  return {
188
198
  ...state,
189
199
  segments: normalizeSegments(segments),
@@ -193,7 +203,7 @@ export function deleteForward(state: InputState): InputState {
193
203
  if (cursor.index + 1 >= segments.length) return state;
194
204
  const next = segments[cursor.index + 1];
195
205
  if (next.type === 'text') {
196
- segment.text += next.text;
206
+ segments[cursor.index] = { ...segment, text: segment.text + next.text };
197
207
  segments.splice(cursor.index + 1, 1);
198
208
  return {
199
209
  ...state,
@@ -221,7 +231,7 @@ export function deleteForward(state: InputState): InputState {
221
231
  if (cursor.index + 1 >= segments.length) return state;
222
232
  const next = segments[cursor.index + 1];
223
233
  if (next.type === 'text') {
224
- next.text = next.text.slice(1);
234
+ segments[cursor.index + 1] = { ...next, text: next.text.slice(1) };
225
235
  return {
226
236
  ...state,
227
237
  segments: normalizeSegments(segments),
@@ -236,6 +246,31 @@ export function deleteForward(state: InputState): InputState {
236
246
  };
237
247
  }
238
248
 
249
+ export function killToEnd(state: InputState): InputState {
250
+ const segments = [...state.segments];
251
+ const cursor = clampCursor(segments, state.cursor);
252
+
253
+ if (cursor.index >= segments.length) return state;
254
+
255
+ const segment = segments[cursor.index];
256
+ if (segment.type === 'text') {
257
+ segment.text = segment.text.slice(0, cursor.offset);
258
+ segments.splice(cursor.index + 1);
259
+ return {
260
+ ...state,
261
+ segments: normalizeSegments(segments),
262
+ cursor: { index: cursor.index, offset: segment.text.length }
263
+ };
264
+ }
265
+
266
+ segments.splice(cursor.index);
267
+ return {
268
+ ...state,
269
+ segments: normalizeSegments(segments),
270
+ cursor: { index: Math.min(cursor.index, segments.length), offset: 0 }
271
+ };
272
+ }
273
+
239
274
  export function moveLeft(state: InputState): InputState {
240
275
  const segments = state.segments;
241
276
  const cursor = clampCursor(segments, state.cursor);
@@ -24,6 +24,7 @@ export interface Style {
24
24
  justifyContent?: 'flex-start' | 'center' | 'flex-end' | 'space-between';
25
25
  borderStyle?: string;
26
26
  borderColor?: string;
27
+ overflow?: 'visible' | 'hidden';
27
28
  }
28
29
 
29
30
  export interface TextStyle {
@@ -1,8 +1,8 @@
1
- import React from 'react';
1
+ import React, { memo } from 'react';
2
2
  import { Box, Text, type BoxProps } from 'ink';
3
3
  import { LayoutNode, TextNode } from '../core/types.js';
4
4
 
5
- function renderText(node: TextNode): React.ReactElement {
5
+ const InkText = memo(function InkText({ node }: { node: TextNode }): React.ReactElement {
6
6
  const style = node.style || {};
7
7
  return (
8
8
  <Text
@@ -15,11 +15,11 @@ function renderText(node: TextNode): React.ReactElement {
15
15
  {node.text}
16
16
  </Text>
17
17
  );
18
- }
18
+ });
19
19
 
20
- export function InkNode({ node }: { node: LayoutNode }): React.ReactElement {
20
+ function InkNodeInner({ node }: { node: LayoutNode }): React.ReactElement {
21
21
  if (node.type === 'text') {
22
- return renderText(node);
22
+ return <InkText node={node} />;
23
23
  }
24
24
 
25
25
  const style = node.style || {};
@@ -33,13 +33,22 @@ export function InkNode({ node }: { node: LayoutNode }): React.ReactElement {
33
33
  padding={style.padding}
34
34
  paddingX={style.paddingX}
35
35
  paddingY={style.paddingY}
36
+ paddingLeft={style.paddingLeft}
37
+ paddingRight={style.paddingRight}
38
+ paddingTop={style.paddingTop}
39
+ paddingBottom={style.paddingBottom}
36
40
  margin={style.margin}
37
41
  marginX={style.marginX}
38
42
  marginY={style.marginY}
43
+ marginLeft={style.marginLeft}
44
+ marginRight={style.marginRight}
45
+ marginTop={style.marginTop}
46
+ marginBottom={style.marginBottom}
39
47
  alignItems={style.alignItems}
40
48
  justifyContent={style.justifyContent}
41
49
  borderStyle={style.borderStyle as BoxProps['borderStyle']}
42
50
  borderColor={style.borderColor}
51
+ overflow={style.overflow}
43
52
  >
44
53
  {node.children.map((child, index) => (
45
54
  <InkNode key={`${child.type}-${index}`} node={child} />
@@ -47,3 +56,5 @@ export function InkNode({ node }: { node: LayoutNode }): React.ReactElement {
47
56
  </Box>
48
57
  );
49
58
  }
59
+
60
+ export const InkNode = memo(InkNodeInner);
@@ -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
+ }
@@ -5,6 +5,7 @@ import { buildHeaderView } from './header.js';
5
5
  import { buildMessageListView } from './message_list.js';
6
6
  import { buildInputAreaView, estimateInputLines } from './input_area.js';
7
7
  import { buildStatusBarView } from './status_bar.js';
8
+ import { buildActivityLineView } from './activity_line.js';
8
9
 
9
10
  interface AppViewProps {
10
11
  messages: Message[];
@@ -15,12 +16,16 @@ interface AppViewProps {
15
16
  rows: number;
16
17
  commands: Array<{ name: string; description: string; usage?: string }>;
17
18
  hasApiKey: boolean;
19
+ version?: string;
18
20
  contextLength?: number;
19
21
  contextEstimated?: boolean;
20
22
  provider?: string;
21
23
  model?: string;
22
24
  emulationId?: string;
23
25
  toast?: string | null;
26
+ spinnerLabel?: string | null;
27
+ spinnerFrame?: string | null;
28
+ inputMode?: 'queue' | 'interrupt';
24
29
  debug?: boolean;
25
30
  expandToolOutputs?: boolean;
26
31
  }
@@ -47,23 +52,28 @@ export function buildAppView({
47
52
  rows,
48
53
  commands,
49
54
  hasApiKey,
55
+ version = '0.1.0',
50
56
  contextLength,
51
57
  contextEstimated,
52
58
  provider,
53
59
  model,
54
60
  emulationId,
61
+ inputMode,
55
62
  toast,
63
+ spinnerLabel,
64
+ spinnerFrame,
56
65
  debug = false,
57
66
  expandToolOutputs = false
58
67
  }: AppViewProps): LayoutNode {
59
68
  const suggestionLines = 4;
60
- const headerHeight = 6; // logo row + meta row + padding
69
+ const headerHeight = 1;
61
70
  const statusHeight = 1;
71
+ const activityHeight = (agentState.status === 'thinking' || agentState.status === 'streaming') && spinnerLabel ? 3 : 0;
62
72
  const inputLineCount = estimateInputLines(inputState.segments, cols);
63
73
  const previewLines = estimateBadgePreviewLines(inputState);
64
- const maxInputLines = Math.max(1, rows - (headerHeight + statusHeight + suggestionLines + 5));
74
+ const maxInputLines = Math.max(1, rows - (headerHeight + statusHeight + activityHeight + suggestionLines + 5));
65
75
  const inputHeight = Math.min(Math.max(1, inputLineCount + previewLines), maxInputLines) + suggestionLines;
66
- const contentHeight = Math.max(rows - (headerHeight + inputHeight + statusHeight), 5);
76
+ const contentHeight = Math.max(rows - (headerHeight + inputHeight + statusHeight + activityHeight), 5);
67
77
 
68
78
  const placeholder = !hasApiKey ? 'Set API key with /config key <key>' :
69
79
  agentState.status === 'thinking' ? 'Thinking...' :
@@ -72,28 +82,30 @@ export function buildAppView({
72
82
  'Type a message or /help for commands...';
73
83
 
74
84
  return box([
75
- buildHeaderView({ version: '0.1.0', debug }),
85
+ buildHeaderView({ version, debug }),
76
86
  buildMessageListView({ messages, height: contentHeight, debug, expandToolOutputs }),
87
+ buildActivityLineView({ state: agentState, spinnerLabel, spinnerFrame, inputMode }),
88
+ buildInputAreaView({
89
+ state: inputState,
90
+ placeholder,
91
+ disabled: agentState.status !== 'idle' && agentState.status !== 'error',
92
+ commands,
93
+ cols,
94
+ debug
95
+ }),
77
96
  buildStatusBarView({
78
97
  state: agentState,
79
98
  sessionId,
80
- version: '0.1.0',
99
+ version,
81
100
  connectionStatus: hasApiKey ? 'connected' : 'disconnected',
82
101
  contextLength,
83
102
  contextEstimated,
84
103
  provider,
85
104
  model,
86
105
  emulationId,
106
+ inputMode,
87
107
  toast,
88
108
  debug
89
- }),
90
- buildInputAreaView({
91
- state: inputState,
92
- placeholder,
93
- disabled: agentState.status !== 'idle' && agentState.status !== 'error',
94
- commands,
95
- cols,
96
- debug
97
109
  })
98
110
  ], { flexDirection: 'column' });
99
111
  }
@@ -18,10 +18,9 @@ export function buildHeaderView({
18
18
  return box([
19
19
  box([
20
20
  text(title, { bold: true }),
21
+ text(' ', {}),
21
22
  text('ZTC', { color: 'gray', dimColor: true })
22
- ], {
23
- flexDirection: 'column'
24
- }),
23
+ ], { flexDirection: 'row' }),
25
24
  showHelp
26
25
  ? box([
27
26
  dateLabel ? text(dateLabel, { color: 'gray', dimColor: true }) : text('', {}),
@@ -34,9 +33,12 @@ export function buildHeaderView({
34
33
  ], { flexDirection: 'row' })
35
34
  : box([], { flexDirection: 'row' })
36
35
  ], {
37
- flexDirection: 'column',
36
+ flexDirection: 'row',
38
37
  justifyContent: 'space-between',
39
38
  paddingX: 1,
40
- paddingY: 1
39
+ flexShrink: 0,
40
+ borderStyle: 'single',
41
+ borderColor: debug ? 'cyan' : 'gray',
42
+ marginBottom: 1
41
43
  });
42
44
  }
@@ -216,19 +216,20 @@ export function buildInputAreaView({
216
216
  borderColor: debug ? (disabled ? 'gray' : 'blue') : undefined
217
217
  });
218
218
 
219
- const suggestions = box(
220
- Array.from({ length: suggestionLines }).map((_, index) => {
221
- const cmd = commandMatches[index];
222
- if (!cmd) return text(' ');
223
- const usage = cmd.usage ? ` ${truncate(cmd.usage, 36)}` : '';
224
- return box([
225
- text(`/${cmd.name}`, { color: 'cyan', bold: true }),
226
- text(usage, { color: 'white' }),
227
- text(` — ${truncate(cmd.description, 48)}`, { color: 'gray', dimColor: true })
228
- ], { flexDirection: 'row' });
229
- }),
230
- { flexDirection: 'column', paddingX: 2 }
231
- );
219
+ // Only render suggestion lines if there are actual command matches
220
+ const suggestions = commandMatches.length > 0
221
+ ? box(
222
+ commandMatches.map((cmd) => {
223
+ const usage = cmd.usage ? ` ${truncate(cmd.usage, 36)}` : '';
224
+ return box([
225
+ text(`/${cmd.name}`, { color: 'cyan', bold: true }),
226
+ text(usage, { color: 'white' }),
227
+ text(` — ${truncate(cmd.description, 48)}`, { color: 'gray', dimColor: true })
228
+ ], { flexDirection: 'row' });
229
+ }),
230
+ { flexDirection: 'column', paddingX: 2 }
231
+ )
232
+ : null;
232
233
 
233
234
  const previewLines = showBadgePreview && badgePreview && badgePreview.length > 0
234
235
  ? box(badgePreview.map(line => text(line, { color: 'gray', dimColor: true })), {
@@ -237,8 +238,18 @@ export function buildInputAreaView({
237
238
  })
238
239
  : null;
239
240
 
240
- return box(
241
- previewLines ? [inputLine, previewLines, suggestions] : [inputLine, suggestions],
242
- { flexDirection: 'column', flexShrink: 0 }
243
- );
241
+ // Horizontal separator lines
242
+ const separatorTop = text('─'.repeat(Math.max(10, cols - 2)), { color: 'gray', dimColor: true });
243
+ const separatorBottom = text('─'.repeat(Math.max(10, cols - 2)), { color: 'gray', dimColor: true });
244
+
245
+ // Build the content array, filtering out nulls
246
+ const content: LayoutNode[] = [separatorTop, inputLine];
247
+ if (previewLines) content.push(previewLines);
248
+ if (suggestions) content.push(suggestions);
249
+ content.push(separatorBottom);
250
+
251
+ return box(content, {
252
+ flexDirection: 'column',
253
+ flexShrink: 0
254
+ });
244
255
  }