zerg-ztc 0.1.4 → 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 (110) hide show
  1. package/dist/App.d.ts.map +1 -1
  2. package/dist/App.js +115 -9
  3. package/dist/App.js.map +1 -1
  4. package/dist/agent/commands/config.d.ts.map +1 -1
  5. package/dist/agent/commands/config.js +68 -2
  6. package/dist/agent/commands/config.js.map +1 -1
  7. package/dist/agent/commands/index.d.ts.map +1 -1
  8. package/dist/agent/commands/index.js +2 -1
  9. package/dist/agent/commands/index.js.map +1 -1
  10. package/dist/agent/commands/update.d.ts +3 -0
  11. package/dist/agent/commands/update.d.ts.map +1 -0
  12. package/dist/agent/commands/update.js +33 -0
  13. package/dist/agent/commands/update.js.map +1 -0
  14. package/dist/cli.js +46 -31
  15. package/dist/cli.js.map +1 -1
  16. package/dist/components/ActivityLine.d.ts +11 -0
  17. package/dist/components/ActivityLine.d.ts.map +1 -0
  18. package/dist/components/ActivityLine.js +9 -0
  19. package/dist/components/ActivityLine.js.map +1 -0
  20. package/dist/components/FullScreen.d.ts +1 -0
  21. package/dist/components/FullScreen.d.ts.map +1 -1
  22. package/dist/components/FullScreen.js +2 -2
  23. package/dist/components/FullScreen.js.map +1 -1
  24. package/dist/components/MessageList.d.ts +2 -1
  25. package/dist/components/MessageList.d.ts.map +1 -1
  26. package/dist/components/MessageList.js +41 -2
  27. package/dist/components/MessageList.js.map +1 -1
  28. package/dist/components/SingleMessage.d.ts +9 -0
  29. package/dist/components/SingleMessage.d.ts.map +1 -0
  30. package/dist/components/SingleMessage.js +27 -0
  31. package/dist/components/SingleMessage.js.map +1 -0
  32. package/dist/components/StatusBar.d.ts +1 -0
  33. package/dist/components/StatusBar.d.ts.map +1 -1
  34. package/dist/components/StatusBar.js +2 -1
  35. package/dist/components/StatusBar.js.map +1 -1
  36. package/dist/components/index.d.ts +2 -0
  37. package/dist/components/index.d.ts.map +1 -1
  38. package/dist/components/index.js +2 -0
  39. package/dist/components/index.js.map +1 -1
  40. package/dist/config/types.d.ts +1 -0
  41. package/dist/config/types.d.ts.map +1 -1
  42. package/dist/config.d.ts.map +1 -1
  43. package/dist/config.js +8 -0
  44. package/dist/config.js.map +1 -1
  45. package/dist/ui/views/activity_line.d.ts +11 -0
  46. package/dist/ui/views/activity_line.d.ts.map +1 -0
  47. package/dist/ui/views/activity_line.js +20 -0
  48. package/dist/ui/views/activity_line.js.map +1 -0
  49. package/dist/ui/views/app.d.ts +5 -2
  50. package/dist/ui/views/app.d.ts.map +1 -1
  51. package/dist/ui/views/app.js +17 -14
  52. package/dist/ui/views/app.js.map +1 -1
  53. package/dist/ui/views/header.d.ts.map +1 -1
  54. package/dist/ui/views/header.js +3 -4
  55. package/dist/ui/views/header.js.map +1 -1
  56. package/dist/ui/views/input_area.d.ts.map +1 -1
  57. package/dist/ui/views/input_area.js +25 -12
  58. package/dist/ui/views/input_area.js.map +1 -1
  59. package/dist/ui/views/message_list.d.ts +3 -2
  60. package/dist/ui/views/message_list.d.ts.map +1 -1
  61. package/dist/ui/views/message_list.js +33 -19
  62. package/dist/ui/views/message_list.js.map +1 -1
  63. package/dist/ui/views/status_bar.d.ts +2 -1
  64. package/dist/ui/views/status_bar.d.ts.map +1 -1
  65. package/dist/ui/views/status_bar.js +4 -2
  66. package/dist/ui/views/status_bar.js.map +1 -1
  67. package/dist/utils/spinner_frames.d.ts +2 -0
  68. package/dist/utils/spinner_frames.d.ts.map +1 -0
  69. package/dist/utils/spinner_frames.js +2 -0
  70. package/dist/utils/spinner_frames.js.map +1 -0
  71. package/dist/utils/spinner_verbs.d.ts +4 -0
  72. package/dist/utils/spinner_verbs.d.ts.map +1 -0
  73. package/dist/utils/spinner_verbs.js +22 -0
  74. package/dist/utils/spinner_verbs.js.map +1 -0
  75. package/dist/utils/tool_trace.d.ts.map +1 -1
  76. package/dist/utils/tool_trace.js +12 -2
  77. package/dist/utils/tool_trace.js.map +1 -1
  78. package/dist/utils/update.d.ts +9 -0
  79. package/dist/utils/update.d.ts.map +1 -0
  80. package/dist/utils/update.js +37 -0
  81. package/dist/utils/update.js.map +1 -0
  82. package/dist/utils/version.d.ts +2 -0
  83. package/dist/utils/version.d.ts.map +1 -0
  84. package/dist/utils/version.js +16 -0
  85. package/dist/utils/version.js.map +1 -0
  86. package/package.json +1 -1
  87. package/src/App.tsx +156 -20
  88. package/src/agent/commands/config.ts +76 -2
  89. package/src/agent/commands/index.ts +2 -0
  90. package/src/agent/commands/update.ts +32 -0
  91. package/src/cli.tsx +50 -30
  92. package/src/components/ActivityLine.tsx +23 -0
  93. package/src/components/FullScreen.tsx +4 -3
  94. package/src/components/MessageList.tsx +52 -6
  95. package/src/components/SingleMessage.tsx +59 -0
  96. package/src/components/StatusBar.tsx +3 -0
  97. package/src/components/index.tsx +3 -1
  98. package/src/config/types.ts +1 -0
  99. package/src/config.ts +8 -0
  100. package/src/ui/views/activity_line.ts +33 -0
  101. package/src/ui/views/app.ts +23 -14
  102. package/src/ui/views/header.ts +3 -4
  103. package/src/ui/views/input_area.ts +28 -17
  104. package/src/ui/views/message_list.ts +36 -20
  105. package/src/ui/views/status_bar.ts +5 -1
  106. package/src/utils/spinner_frames.ts +1 -0
  107. package/src/utils/spinner_verbs.ts +23 -0
  108. package/src/utils/tool_trace.ts +12 -2
  109. package/src/utils/update.ts +44 -0
  110. package/src/utils/version.ts +15 -0
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
+ }
@@ -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,13 +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
- inputMode?: 'queue' | 'interrupt';
24
25
  toast?: string | null;
26
+ spinnerLabel?: string | null;
27
+ spinnerFrame?: string | null;
28
+ inputMode?: 'queue' | 'interrupt';
25
29
  debug?: boolean;
26
30
  expandToolOutputs?: boolean;
27
31
  }
@@ -48,6 +52,7 @@ export function buildAppView({
48
52
  rows,
49
53
  commands,
50
54
  hasApiKey,
55
+ version = '0.1.0',
51
56
  contextLength,
52
57
  contextEstimated,
53
58
  provider,
@@ -55,17 +60,20 @@ export function buildAppView({
55
60
  emulationId,
56
61
  inputMode,
57
62
  toast,
63
+ spinnerLabel,
64
+ spinnerFrame,
58
65
  debug = false,
59
66
  expandToolOutputs = false
60
67
  }: AppViewProps): LayoutNode {
61
68
  const suggestionLines = 4;
62
- const headerHeight = 6; // logo row + meta row + padding
69
+ const headerHeight = 1;
63
70
  const statusHeight = 1;
71
+ const activityHeight = (agentState.status === 'thinking' || agentState.status === 'streaming') && spinnerLabel ? 3 : 0;
64
72
  const inputLineCount = estimateInputLines(inputState.segments, cols);
65
73
  const previewLines = estimateBadgePreviewLines(inputState);
66
- const maxInputLines = Math.max(1, rows - (headerHeight + statusHeight + suggestionLines + 5));
74
+ const maxInputLines = Math.max(1, rows - (headerHeight + statusHeight + activityHeight + suggestionLines + 5));
67
75
  const inputHeight = Math.min(Math.max(1, inputLineCount + previewLines), maxInputLines) + suggestionLines;
68
- const contentHeight = Math.max(rows - (headerHeight + inputHeight + statusHeight), 5);
76
+ const contentHeight = Math.max(rows - (headerHeight + inputHeight + statusHeight + activityHeight), 5);
69
77
 
70
78
  const placeholder = !hasApiKey ? 'Set API key with /config key <key>' :
71
79
  agentState.status === 'thinking' ? 'Thinking...' :
@@ -74,12 +82,21 @@ export function buildAppView({
74
82
  'Type a message or /help for commands...';
75
83
 
76
84
  return box([
77
- buildHeaderView({ version: '0.1.0', debug }),
85
+ buildHeaderView({ version, debug }),
78
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
+ }),
79
96
  buildStatusBarView({
80
97
  state: agentState,
81
98
  sessionId,
82
- version: '0.1.0',
99
+ version,
83
100
  connectionStatus: hasApiKey ? 'connected' : 'disconnected',
84
101
  contextLength,
85
102
  contextEstimated,
@@ -89,14 +106,6 @@ export function buildAppView({
89
106
  inputMode,
90
107
  toast,
91
108
  debug
92
- }),
93
- buildInputAreaView({
94
- state: inputState,
95
- placeholder,
96
- disabled: agentState.status !== 'idle' && agentState.status !== 'error',
97
- commands,
98
- cols,
99
- debug
100
109
  })
101
110
  ], { flexDirection: 'column' });
102
111
  }
@@ -15,7 +15,6 @@ export function buildHeaderView({
15
15
  showHelp = true,
16
16
  debug = false
17
17
  }: HeaderProps): LayoutNode {
18
- // Single-row header to minimize vertical space
19
18
  return box([
20
19
  box([
21
20
  text(title, { bold: true }),
@@ -37,9 +36,9 @@ export function buildHeaderView({
37
36
  flexDirection: 'row',
38
37
  justifyContent: 'space-between',
39
38
  paddingX: 1,
40
- height: 1,
41
39
  flexShrink: 0,
42
- borderStyle: debug ? 'single' : undefined,
43
- borderColor: debug ? 'cyan' : undefined
40
+ borderStyle: 'single',
41
+ borderColor: debug ? 'cyan' : 'gray',
42
+ marginBottom: 1
44
43
  });
45
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
  }