agent-termo 1.0.0 → 1.0.2

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 (65) hide show
  1. package/README.md +30 -11
  2. package/dist/agents/index.d.ts +5 -0
  3. package/dist/agents/index.d.ts.map +1 -0
  4. package/dist/agents/index.js +86 -0
  5. package/dist/agents/index.js.map +1 -0
  6. package/dist/cli.d.ts +3 -0
  7. package/dist/cli.d.ts.map +1 -0
  8. package/dist/cli.js +97 -164
  9. package/dist/cli.js.map +4 -4
  10. package/dist/diff/index.d.ts +9 -0
  11. package/dist/diff/index.d.ts.map +1 -0
  12. package/dist/diff/index.js +213 -0
  13. package/dist/diff/index.js.map +1 -0
  14. package/dist/llm/client.d.ts +39 -0
  15. package/dist/llm/client.d.ts.map +1 -0
  16. package/dist/llm/client.js +188 -0
  17. package/dist/llm/client.js.map +1 -0
  18. package/dist/llm/models.d.ts +19 -0
  19. package/dist/llm/models.d.ts.map +1 -0
  20. package/dist/llm/models.js +45 -0
  21. package/dist/llm/models.js.map +1 -0
  22. package/dist/llm/tools.d.ts +3 -0
  23. package/dist/llm/tools.d.ts.map +1 -0
  24. package/dist/llm/tools.js +204 -0
  25. package/dist/llm/tools.js.map +1 -0
  26. package/dist/main.d.ts +2 -0
  27. package/dist/main.d.ts.map +1 -0
  28. package/dist/main.js +91 -140
  29. package/dist/main.js.map +4 -4
  30. package/dist/memory/index.d.ts +10 -0
  31. package/dist/memory/index.d.ts.map +1 -0
  32. package/dist/memory/index.js +117 -0
  33. package/dist/memory/index.js.map +1 -0
  34. package/dist/tools/index.d.ts +5 -0
  35. package/dist/tools/index.d.ts.map +1 -0
  36. package/dist/tools/index.js +334 -0
  37. package/dist/tools/index.js.map +1 -0
  38. package/dist/types/index.d.ts +119 -0
  39. package/dist/types/index.d.ts.map +1 -0
  40. package/dist/types/index.js +5 -0
  41. package/dist/types/index.js.map +1 -0
  42. package/dist/ui/index.d.ts +47 -0
  43. package/dist/ui/index.d.ts.map +1 -0
  44. package/dist/ui/index.js +321 -0
  45. package/dist/ui/index.js.map +1 -0
  46. package/dist/ui/screen.d.ts +59 -0
  47. package/dist/ui/screen.d.ts.map +1 -0
  48. package/dist/ui/screen.js +629 -0
  49. package/dist/ui/screen.js.map +1 -0
  50. package/dist/utils/ansi.d.ts +62 -0
  51. package/dist/utils/ansi.d.ts.map +1 -0
  52. package/dist/utils/ansi.js +116 -0
  53. package/dist/utils/ansi.js.map +1 -0
  54. package/dist/utils/config.d.ts +9 -0
  55. package/dist/utils/config.d.ts.map +1 -0
  56. package/dist/utils/config.js +120 -0
  57. package/dist/utils/config.js.map +1 -0
  58. package/package.json +2 -2
  59. package/src/cli.ts +91 -12
  60. package/src/llm/client.ts +24 -0
  61. package/src/llm/models.ts +55 -0
  62. package/src/main.ts +201 -75
  63. package/src/ui/index.ts +256 -389
  64. package/src/ui/screen.ts +698 -0
  65. package/src/utils/config.ts +62 -2
package/src/ui/index.ts CHANGED
@@ -1,58 +1,175 @@
1
1
  // =============================================================================
2
- // Terminal UI — chalk-based, clean CLI styling with diff viewer
2
+ // Terminal UI — chalk-based styling, markdown output, diff viewer
3
+ // Layout (pinned input box, scroll region) and keyboard input live in screen.ts
3
4
  // =============================================================================
4
5
 
5
6
  import Chalk from 'chalk';
6
7
  import type { ProposedFileChange } from '../types/index.js';
7
8
  import { loadAgentTermoMemory, loadAgentTermoTimeline } from '../memory/index.js';
8
- import type { ConversationMessage } from '../types/index.js';
9
+ import {
10
+ clipText,
11
+ listenKeys,
12
+ resetScreen,
13
+ setCommands,
14
+ terminalWidth,
15
+ type CommandInfo,
16
+ } from './screen.js';
17
+
18
+ export {
19
+ createInput,
20
+ initScreen,
21
+ pick,
22
+ setScreenStatus,
23
+ type InputController,
24
+ type PickItem,
25
+ } from './screen.js';
9
26
 
10
27
  const c: typeof Chalk = Chalk as unknown as typeof Chalk;
28
+ const out = process.stdout;
29
+
30
+ export const COMMANDS: CommandInfo[] = [
31
+ { name: '/help', description: 'show all commands' },
32
+ { name: '/clear', description: 'clear conversation & screen' },
33
+ { name: '/memory', description: 'view Agent-Termo memory' },
34
+ { name: '/timeline', description: 'view recent activity' },
35
+ { name: '/model', description: 'switch NVIDIA NIM model (or /model <id>)' },
36
+ { name: '/agents', description: 'list all agents' },
37
+ { name: '/history', description: 'show message count' },
38
+ { name: '/exit', description: 'quit' },
39
+ ];
40
+ setCommands(COMMANDS);
41
+
42
+ // eslint-disable-next-line no-control-regex
43
+ const ANSI_RE = /\x1b\[[0-9;?]*[A-Za-z]/g;
44
+ const visibleLength = (s: string): number => s.replace(ANSI_RE, '').length;
45
+ const rule = (): string => c.dim('─'.repeat(Math.max(10, Math.min(terminalWidth() - 2, 100))));
11
46
 
12
47
  // =============================================================================
13
48
  // WELCOME BANNER
14
49
  // =============================================================================
15
50
 
16
- export function printWelcome(projectRoot: string): void {
17
- console.log('');
18
- console.log(c.magenta.bold(' AGENT-TERMO'));
19
- console.log(c.dim(' AI-Powered Terminal Coding Agent'));
20
- console.log('');
21
- console.log(c.dim(' Model: ') + c.cyan.bold('Nvidia NIM (OpenAI-compatible)'));
22
- console.log(c.dim(' Project: ') + c.white(projectRoot));
23
- console.log('');
24
- console.log(c.dim(' Commands:'));
25
- console.log(
26
- ' ' + c.cyan('/help') + c.dim(' — show all commands') + '\n' +
27
- ' ' + c.cyan('/clear') + c.dim(' — clear conversation & screen') + '\n' +
28
- ' ' + c.cyan('/memory') + c.dim(' — view Agent-Termo memory') + '\n' +
29
- ' ' + c.cyan('/timeline') + c.dim(' — view recent activity') + '\n' +
30
- ' ' + c.cyan('/model') + c.dim(' — show current AI model') + '\n' +
31
- ' ' + c.cyan('/agents') + c.dim(' — list all agents') + '\n' +
32
- ' ' + c.cyan('/history') + c.dim(' — show message count') + '\n' +
33
- ' ' + c.cyan('/exit') + c.dim(' — quit'),
51
+ export function printWelcome(projectRoot: string, model: string): void {
52
+ const width = Math.max(40, Math.min(terminalWidth() - 2, 64));
53
+ const inner = width - 2;
54
+ const room = inner - 2;
55
+ const row = (content: string): string =>
56
+ ' ' + c.magenta('│') + ' ' + content + ' '.repeat(Math.max(0, room - visibleLength(content))) + ' ' + c.magenta('│');
57
+ const field = (label: string, value: string): string =>
58
+ c.dim(label.padEnd(7)) + c.cyan(clipText(value, room - 7));
59
+
60
+ out.write('\n');
61
+ out.write(' ' + c.magenta('╭' + '─'.repeat(inner) + '╮') + '\n');
62
+ out.write(row(c.magenta.bold('✻ Agent-Termo')) + '\n');
63
+ out.write(row(c.dim(clipText('AI coding agent for your terminal', room))) + '\n');
64
+ out.write(row('') + '\n');
65
+ out.write(row(field('model', model)) + '\n');
66
+ out.write(row(field('cwd', projectRoot)) + '\n');
67
+ out.write(' ' + c.magenta('╰' + '─'.repeat(inner) + '╯') + '\n');
68
+ out.write('\n');
69
+ out.write(
70
+ c.dim(' ') + c.cyan('/help') + c.dim(' commands · ') + c.cyan('/model') + c.dim(' switch NIM model · ') +
71
+ c.cyan('Tab') + c.dim(' completes') + '\n\n',
34
72
  );
35
- console.log('');
36
- console.log(c.dim(' ────────────────────────────────────────────────────────────────'));
37
- console.log('');
73
+ }
74
+
75
+ // =============================================================================
76
+ // MARKDOWN OUTPUT — line-buffered so streamed replies render as they arrive
77
+ // =============================================================================
78
+
79
+ function inline(text: string): string {
80
+ return text
81
+ .split(/(`[^`]+`)/)
82
+ .map((seg) =>
83
+ seg.length > 1 && seg.startsWith('`') && seg.endsWith('`')
84
+ ? c.cyan(seg.slice(1, -1))
85
+ : seg.replace(/\*\*(.+?)\*\*/g, (_m, g: string) => c.bold(g)),
86
+ )
87
+ .join('');
88
+ }
89
+
90
+ export class MarkdownPrinter {
91
+ private pending = '';
92
+ private inCode = false;
93
+ private first = true;
94
+ private lastBlank = false;
95
+
96
+ /** `beforeFirstOutput` runs once, just before the first line is printed (stop the spinner there). */
97
+ constructor(private readonly beforeFirstOutput?: () => void) {}
98
+
99
+ push(text: string): void {
100
+ this.pending += text;
101
+ let nl = this.pending.indexOf('\n');
102
+ while (nl !== -1) {
103
+ const line = this.pending.slice(0, nl).replace(/\r$/, '');
104
+ this.pending = this.pending.slice(nl + 1);
105
+ this.emit(line);
106
+ nl = this.pending.indexOf('\n');
107
+ }
108
+ }
109
+
110
+ end(): void {
111
+ if (this.pending) this.emit(this.pending.replace(/\r$/, ''));
112
+ this.pending = '';
113
+ }
114
+
115
+ get printed(): boolean {
116
+ return !this.first;
117
+ }
118
+
119
+ private emit(line: string): void {
120
+ const blank = line.trim() === '';
121
+ if (!this.inCode && blank && (this.first || this.lastBlank)) return;
122
+ this.lastBlank = blank && !this.inCode;
123
+
124
+ if (this.first) {
125
+ this.beforeFirstOutput?.();
126
+ out.write('\n');
127
+ }
128
+ const prefix = this.first ? c.magenta('● ') : ' ';
129
+ this.first = false;
130
+ // Blank prose lines stay empty (no trailing indent); code keeps its gutter.
131
+ out.write(blank && !this.inCode ? '\n' : prefix + this.render(line) + '\n');
132
+ }
133
+
134
+ private render(line: string): string {
135
+ const fence = /^\s*```\s*([\w+-]*)/.exec(line);
136
+ if (fence) {
137
+ this.inCode = !this.inCode;
138
+ return this.inCode ? c.dim('┌─ ' + (fence[1] || 'code')) : c.dim('└─');
139
+ }
140
+ if (this.inCode) return c.dim('│ ') + c.cyan(line.replace(/\t/g, ' '));
141
+
142
+ const heading = /^#{1,6}\s+(.*)$/.exec(line);
143
+ if (heading) return c.bold.magenta(inline(heading[1] ?? ''));
144
+ if (/^\s*([-*_])\1{2,}\s*$/.test(line)) return c.dim('─'.repeat(Math.min(40, terminalWidth() - 4)));
145
+
146
+ const bullet = /^(\s*)[-*+]\s+(.*)$/.exec(line);
147
+ if (bullet) return (bullet[1] ?? '') + c.magenta('•') + ' ' + inline(bullet[2] ?? '');
148
+
149
+ const numbered = /^(\s*)(\d+)[.)]\s+(.*)$/.exec(line);
150
+ if (numbered) return (numbered[1] ?? '') + c.magenta(numbered[2] + '.') + ' ' + inline(numbered[3] ?? '');
151
+
152
+ const quote = /^>\s?(.*)$/.exec(line);
153
+ if (quote) return c.dim('▎ ') + c.italic(inline(quote[1] ?? ''));
154
+
155
+ return inline(line);
156
+ }
38
157
  }
39
158
 
40
159
  // =============================================================================
41
160
  // MESSAGE DISPLAY
42
161
  // =============================================================================
43
162
 
44
- export function printUserMessage(msg: ConversationMessage): void {
45
- const time = new Date(msg.timestamp).toLocaleTimeString('en-US', { hour12: false });
46
- console.log('\n' + c.green('[' + time + '] You'));
47
- console.log(c.dim('─'.repeat(80)));
48
- console.log(c.green(msg.content) + '\n');
163
+ export function printUserMessage(text: string): void {
164
+ const lines = text.split('\n');
165
+ out.write('\n' + c.green.bold('❯ ') + c.bold(lines[0] ?? '') + '\n');
166
+ for (const l of lines.slice(1)) out.write(' ' + c.bold(l) + '\n');
49
167
  }
50
168
 
51
169
  export function printAssistantMessage(content: string): void {
52
- const time = new Date().toLocaleTimeString('en-US', { hour12: false });
53
- console.log('\n' + c.magenta('[' + time + '] Agent-Termo'));
54
- console.log(c.dim('─'.repeat(80)));
55
- console.log(c.magenta(content) + '\n');
170
+ const md = new MarkdownPrinter();
171
+ md.push(content);
172
+ md.end();
56
173
  }
57
174
 
58
175
  // =============================================================================
@@ -60,13 +177,14 @@ export function printAssistantMessage(content: string): void {
60
177
  // =============================================================================
61
178
 
62
179
  export function printToolCall(toolName: string, args: Record<string, unknown>): void {
63
- const argsSummary = Object.entries(args)
64
- .map(([k, v]) => k + '=' + JSON.stringify(v))
180
+ const summary = Object.entries(args)
181
+ .map(([k, v]) => {
182
+ const raw = typeof v === 'string' ? v : JSON.stringify(v);
183
+ return k + '=' + clipText(String(raw ?? '').replace(/\s+/g, ' '), 40);
184
+ })
65
185
  .join(', ');
66
- console.log('');
67
- console.log(c.blue('[Tool] ') + c.cyan(toolName));
68
- const truncated = argsSummary.slice(0, 200);
69
- if (truncated) console.log(c.dim(' ' + truncated));
186
+ const room = terminalWidth() - toolName.length - 6;
187
+ out.write('\n' + c.blue('● ') + c.bold(toolName) + c.dim('(' + clipText(summary, room) + ')') + '\n');
70
188
  }
71
189
 
72
190
  export function printToolResult(
@@ -74,122 +192,81 @@ export function printToolResult(
74
192
  result: { success: boolean; output?: string; error?: string },
75
193
  parsedOutput?: { needsApproval?: boolean; proposedChange?: ProposedFileChange },
76
194
  ): void {
77
- // If the tool result contains a proposed change, show the diff + prompt
195
+ // A proposed file change is shown as a diff, then the accept/reject prompt.
78
196
  if (parsedOutput?.needsApproval && parsedOutput.proposedChange) {
79
- console.log('');
80
197
  printDiffViewer(parsedOutput.proposedChange);
81
198
  return;
82
199
  }
83
200
 
201
+ const room = terminalWidth() - 8;
84
202
  if (result.success) {
85
- const out = result.output
86
- ? result.output.slice(0, 200) + (result.output.length > 200 ? '...' : '')
87
- : '(success)';
88
- console.log('');
89
- console.log(c.green('[Done] ') + c.cyan(toolName) + ' ' + c.green('OK'));
90
- const short = out.length > 80 ? out.slice(0, 80) + '...' : out;
91
- console.log(c.green(' ' + short));
203
+ // One line of output is shown as-is; longer output (file contents, command
204
+ // logs) is summarised, since the full text goes to the model.
205
+ const lines = (result.output ?? '').split('\n').filter((l) => l.trim() !== '');
206
+ const summary = lines.length > 1 ? `${lines.length} lines` : clipText((lines[0] ?? '').trim() || 'done', room);
207
+ out.write(c.dim(' ⎿ ') + c.green('✓ ') + summary + '\n');
92
208
  } else {
93
- console.log('');
94
- console.log(c.red('[Error] ') + c.red(toolName + ': ' + (result.error?.slice(0, 200) ?? 'unknown error')));
209
+ const message = (result.error ?? 'unknown error').split('\n')[0] ?? 'unknown error';
210
+ out.write(c.dim(' ⎿ ') + c.red('✗ ' + clipText(`${toolName}: ${message}`, room)) + '\n');
95
211
  }
96
212
  }
97
213
 
98
214
  // =============================================================================
99
- // DIFF VIEWER — Claude Code-style change display
215
+ // DIFF VIEWER
100
216
  // =============================================================================
101
217
 
102
218
  export function printDiffViewer(change: ProposedFileChange): void {
103
219
  const diff = change.diff;
220
+ const room = Math.max(20, terminalWidth() - 14);
104
221
 
105
- // File header
106
- console.log('');
107
- console.log(c.bold(c.cyan(' ─── ' + diff.file + ' ───')));
108
-
109
- // Stat line
110
- const actionLabel = change.isDeletion
111
- ? c.red('[- DELETE]')
222
+ const label = change.isDeletion
223
+ ? c.red('DELETE')
112
224
  : change.isNewFile
113
- ? c.green('[+ NEW]')
114
- : c.blue('[~ MODIFY]');
115
- const stat = c.dim('+' + diff.additions) + ' ' + c.red('-' + diff.deletions);
116
- console.log(' ' + actionLabel + ' ' + stat);
225
+ ? c.green('NEW')
226
+ : c.blue('MODIFY');
227
+
228
+ out.write('\n ' + c.bold.cyan(diff.file) + ' ' + label + ' ' + c.green('+' + diff.additions) + ' ' + c.red('-' + diff.deletions) + '\n');
229
+ out.write(' ' + c.dim('─'.repeat(Math.max(10, Math.min(terminalWidth() - 4, 100)))) + '\n');
117
230
 
118
- // Diff lines
119
231
  for (const line of diff.lines) {
120
232
  const prefix = line.type === 'add' ? '+' : line.type === 'remove' ? '-' : ' ';
121
233
  const color = line.type === 'add' ? c.green : line.type === 'remove' ? c.red : c.dim;
122
- const lineNum = line.lineNumber > 0 ? String(line.lineNumber).padStart(4) : ' ';
123
- console.log(' ' + color(prefix + lineNum + '│' + line.content));
234
+ const num = line.lineNumber > 0 ? String(line.lineNumber).padStart(4) : ' ';
235
+ const text = clipText(line.content.replace(/\t/g, ' '), room);
236
+ out.write(' ' + color(prefix + num + ' │ ' + text) + '\n');
124
237
  }
125
-
126
- // Summary footer
127
- console.log('');
128
- console.log(c.dim(' ── ') + c.green('+' + diff.additions) + c.dim(' added, ') + c.red('-' + diff.deletions) + c.dim(' removed ') + c.dim('─'));
238
+ out.write('\n');
129
239
  }
130
240
 
131
241
  // =============================================================================
132
- // ACCEPT / REJECT PROMPT — Claude Code-style keyboard interaction
242
+ // ACCEPT / REJECT PROMPT
133
243
  // =============================================================================
134
244
 
135
- export async function promptAcceptReject(message: string = 'Accept this change?'): Promise<'accept' | 'reject'> {
136
- process.stdout.write('\n' + c.yellow(message) + ' ');
137
- process.stdout.write(c.dim('[') + c.green('Y') + c.dim('/') + c.red('n') + c.dim('] '));
245
+ export function promptAcceptReject(message: string = 'Accept this change?'): Promise<'accept' | 'reject'> {
246
+ out.write(
247
+ '\n' + c.yellow('? ') + c.bold(message) + c.dim(' [') + c.green('Y') + c.dim('/') + c.red('n') + c.dim('] '),
248
+ );
138
249
 
139
250
  return new Promise((resolve) => {
140
- // Allow a flag to auto-reject (used when detecting stale REPL listeners)
141
- let alreadyHandled = false;
142
-
143
- const onData = (key: string): void => {
144
- if (alreadyHandled) return;
145
- alreadyHandled = true;
146
- const keyName = getKeyName(key);
147
-
148
- switch (keyName) {
149
- case 'y':
150
- case 'Y':
151
- case 'enter':
152
- case 'return':
153
- process.stdout.write(c.green('yes') + '\n');
154
- cleanup();
155
- resolve('accept');
156
- break;
157
- case 'n':
158
- case 'N':
159
- case 'escape':
160
- process.stdout.write(c.red('no') + '\n');
161
- cleanup();
162
- resolve('reject');
163
- break;
164
- case 'ctrl+c':
165
- process.stdout.write(c.red('cancelled') + '\n');
166
- cleanup();
167
- resolve('reject');
168
- break;
169
- default:
170
- // Ignore other keys, re-arm for next key
171
- alreadyHandled = false;
172
- break;
173
- }
174
- };
175
-
176
- const cleanup = (): void => {
177
- process.stdin.setRawMode(false);
178
- process.stdin.pause();
179
- process.stdin.removeListener('data', onData);
251
+ let done = false;
252
+ let stop: () => void = () => undefined;
253
+
254
+ const answer = (accepted: boolean | 'cancelled'): void => {
255
+ done = true;
256
+ stop();
257
+ out.write(
258
+ accepted === true ? c.green('yes') + '\n' : accepted === false ? c.red('no') + '\n' : c.red('cancelled') + '\n',
259
+ );
260
+ resolve(accepted === true ? 'accept' : 'reject');
180
261
  };
181
262
 
182
- // Remove any other stale data listeners to prevent double-handling
183
- const listeners = process.stdin.listenerCount('data');
184
- if (listeners > 5) {
185
- // Sanity: if there are too many listeners, something went wrong
186
- process.stdin.removeAllListeners('data');
187
- }
188
-
189
- process.stdin.setRawMode(true);
190
- process.stdin.resume();
191
- process.stdin.setEncoding('utf8');
192
- process.stdin.on('data', onData);
263
+ stop = listenKeys((key) => {
264
+ if (done) return;
265
+ const first = key.text?.[0]?.toLowerCase();
266
+ if (key.name === 'enter' || first === 'y') answer(true);
267
+ else if (key.name === 'escape' || first === 'n') answer(false);
268
+ else if (key.name === 'ctrl+c') answer('cancelled');
269
+ });
193
270
  });
194
271
  }
195
272
 
@@ -198,65 +275,45 @@ export async function promptAcceptReject(message: string = 'Accept this change?'
198
275
  // =============================================================================
199
276
 
200
277
  export function printError(message: string, hint?: string): void {
201
- console.log('');
202
- console.log(c.red('[Error] ' + message));
203
- if (hint) console.log(c.dim(' Hint: ' + hint));
278
+ out.write('\n' + c.red('✗ ' + message) + '\n');
279
+ if (hint) out.write(c.dim(' Hint: ' + hint) + '\n');
204
280
  }
205
281
 
206
282
  export function printSuccess(message: string): void {
207
- console.log(c.green('Done: ' + message));
283
+ out.write(c.green('✓ ' + message) + '\n');
208
284
  }
209
285
 
210
286
  export function printInfo(message: string): void {
211
- console.log(c.blue('[Info] ') + message);
287
+ out.write(c.blue('ℹ ') + message + '\n');
212
288
  }
213
289
 
214
290
  // =============================================================================
215
291
  // AGENT-TERMO MEMORY + TIMELINE DISPLAY
216
292
  // =============================================================================
217
293
 
218
- function clearCurrentLine(): void {
219
- process.stdout.write('\x1b[2K');
220
- }
221
-
222
294
  export async function printMemoryFile(): Promise<void> {
223
295
  const mem = loadAgentTermoMemory();
224
296
  if (!mem) {
225
- process.stdout.write('\n');
226
- clearCurrentLine();
227
- process.stdout.write(c.blue('[Info] ') + 'Agent-Termo memory is empty. (Normal for new sessions.)\n');
297
+ printInfo('Agent-Termo memory is empty. (Normal for new sessions.)');
228
298
  return;
229
299
  }
230
- process.stdout.write('\n');
231
- clearCurrentLine();
232
- process.stdout.write(c.bold('=== AGENT-TERMO MEMORY ===') + '\n');
233
- process.stdout.write(c.dim('─'.repeat(80)) + '\n');
234
- process.stdout.write(mem + '\n');
235
- process.stdout.write(c.dim('─'.repeat(80)) + '\n\n');
300
+ out.write('\n' + c.bold('Agent-Termo memory') + '\n' + rule() + '\n' + mem + '\n' + rule() + '\n\n');
236
301
  }
237
302
 
238
303
  export async function printTimelineFile(): Promise<void> {
239
304
  const tl = loadAgentTermoTimeline();
240
305
  if (!tl || tl.trim().length === 0) {
241
- process.stdout.write('\n');
242
- clearCurrentLine();
243
- process.stdout.write(c.blue('[Info] ') + 'Timeline is empty.\n');
306
+ printInfo('Timeline is empty.');
244
307
  return;
245
308
  }
246
309
  const lines = tl.split('\n');
247
- const totalLines = lines.length;
248
- const recentCount = Math.min(40, totalLines);
249
- const recent = lines.slice(-recentCount).join('\n');
250
- process.stdout.write('\n');
251
- clearCurrentLine();
252
- process.stdout.write(c.bold('=== AGENT-TERMO TIMELINE (recent entries) ===') + '\n');
253
- process.stdout.write(c.dim('─'.repeat(80)) + '\n');
254
- process.stdout.write(recent + '\n');
255
- process.stdout.write(c.dim('─'.repeat(80)) + '\n');
256
- if (totalLines > recentCount) {
257
- process.stdout.write(c.dim('... (' + (totalLines - recentCount) + ' earlier entries hidden)') + '\n');
310
+ const recentCount = Math.min(40, lines.length);
311
+ out.write('\n' + c.bold('Agent-Termo timeline') + c.dim(' (recent entries)') + '\n' + rule() + '\n');
312
+ out.write(lines.slice(-recentCount).join('\n') + '\n' + rule() + '\n');
313
+ if (lines.length > recentCount) {
314
+ out.write(c.dim('… ' + (lines.length - recentCount) + ' earlier entries hidden') + '\n');
258
315
  }
259
- process.stdout.write('\n');
316
+ out.write('\n');
260
317
  }
261
318
 
262
319
  // =============================================================================
@@ -264,256 +321,66 @@ export async function printTimelineFile(): Promise<void> {
264
321
  // =============================================================================
265
322
 
266
323
  export function printHelp(): void {
267
- process.stdout.write('\n' + c.bold('Agent-Termo ') + c.dim('— available commands') + '\n');
268
- process.stdout.write(c.dim('─'.repeat(80)) + '\n');
269
- console.log(' ' + c.cyan('/help') + c.dim(' show all commands'));
270
- console.log(' ' + c.cyan('/clear') + c.dim(' clear conversation & screen'));
271
- console.log(' ' + c.cyan('/memory') + c.dim(' view Agent-Termo memory'));
272
- console.log(' ' + c.cyan('/timeline') + c.dim(' view recent activity'));
273
- console.log(' ' + c.cyan('/model') + c.dim(' show current AI model'));
274
- console.log(' ' + c.cyan('/agents') + c.dim(' list all agents'));
275
- console.log(' ' + c.cyan('/history') + c.dim(' show message count'));
276
- console.log(' ' + c.cyan('/exit') + c.dim(' quit'));
277
- console.log('');
324
+ out.write('\n' + c.bold('Commands') + '\n' + rule() + '\n');
325
+ for (const cmd of COMMANDS) {
326
+ out.write(' ' + c.cyan(cmd.name.padEnd(11)) + c.dim(cmd.description) + '\n');
327
+ }
328
+ out.write('\n' + c.bold('Keys') + '\n' + rule() + '\n');
329
+ const keys: Array<[string, string]> = [
330
+ ['↑ / ↓', 'input history'],
331
+ ['Tab', 'complete a /command'],
332
+ ['Ctrl+A / Ctrl+E', 'start / end of line'],
333
+ ['Ctrl+W / Ctrl+U', 'delete word / to line start'],
334
+ ['Ctrl+C', 'clear the line, or quit when empty'],
335
+ ];
336
+ for (const [k, d] of keys) out.write(' ' + c.cyan(k.padEnd(17)) + c.dim(d) + '\n');
337
+ out.write('\n');
278
338
  }
279
339
 
280
340
  export function printAgentsList(agents: Array<{ name: string; description: string }>): void {
281
- process.stdout.write('\n' + c.bold('Available Agents:') + '\n');
282
- process.stdout.write(c.dim('─'.repeat(80)) + '\n');
341
+ out.write('\n' + c.bold('Available agents') + '\n' + rule() + '\n');
342
+ const room = Math.max(10, terminalWidth() - 34);
283
343
  for (const a of agents) {
284
- const short = a.description.length > 40 ? a.description.slice(0, 40) + '...' : a.description;
285
- process.stdout.write(' ' + c.cyan(a.name.padEnd(28)) + ' ' + c.dim(short) + '\n');
344
+ out.write(' ' + c.cyan(a.name.padEnd(30)) + c.dim(clipText(a.description.replace(/\s+/g, ' '), room)) + '\n');
286
345
  }
287
- process.stdout.write(c.dim('─'.repeat(80)) + '\n\n');
288
- }
289
-
290
- // =============================================================================
291
- // INPUT STATE + REPL LOOP
292
- // =============================================================================
293
-
294
- class InputState {
295
- line = '';
296
- cursor = 0;
297
- histIdx = -1;
298
- history: string[] = [];
299
- boundOnData: ((key: string) => void) | null = null;
300
-
301
- constructor() { this.history = []; }
346
+ out.write('\n');
302
347
  }
303
348
 
304
349
  // =============================================================================
305
- // Cursor helpers
350
+ // SPINNER
306
351
  // =============================================================================
307
352
 
308
- export function saveCursorPosition(): void {
309
- process.stdout.write('\x1b[s');
310
- }
311
-
312
- export function restoreCursorPosition(): void {
313
- process.stdout.write('\x1b[u');
314
- }
353
+ const FRAMES = ['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏'];
354
+ let spinnerTimer: ReturnType<typeof setInterval> | null = null;
315
355
 
316
- export function moveToFreshLine(): void {
317
- process.stdout.write(' '.repeat(200) + '\r');
318
- }
356
+ export function startSpinner(message: string): void {
357
+ stopSpinner();
358
+ if (!out.isTTY) {
359
+ out.write(c.dim(message + '…') + '\n');
360
+ return;
361
+ }
319
362
 
320
- /**
321
- * Create an input handler that manages stdin raw mode for the REPL.
322
- * IMPORTANT: The listener is removed when Enter/Return is pressed to
323
- * prevent stale listeners from interfering with promptAcceptReject().
324
- */
325
- export function createInput(): {
326
- question: (prompt: string) => Promise<string>;
327
- close: () => void;
328
- showPrompt: () => void;
329
- } {
330
- let closeFn: (() => void) | null = null;
331
-
332
- return {
333
- async question(_prompt: string): Promise<string> {
334
- return new Promise<string>((resolve) => {
335
- process.stdin.setRawMode(true);
336
- process.stdin.resume();
337
- process.stdin.setEncoding('utf8');
338
- process.stdin.setMaxListeners(20);
339
-
340
- const state = new InputState();
341
- const inputPrompt = c.magenta('> ');
342
-
343
- function finalize(l: string): void {
344
- if (l.trim()) {
345
- state.history.unshift(l);
346
- if (state.history.length > 50) state.history.pop();
347
- }
348
- state.histIdx = -1;
349
- }
350
-
351
- closeFn = (): void => {
352
- process.stdin.setRawMode(false);
353
- process.stdin.pause();
354
- finalize('');
355
- if (state.boundOnData) {
356
- process.stdin.removeListener('data', state.boundOnData);
357
- state.boundOnData = null;
358
- }
359
- };
360
-
361
- moveToFreshLine();
362
- process.stdout.write(inputPrompt);
363
- saveCursorPosition();
364
- process.stdout.write('\x1b[?25h');
365
-
366
- function onData(key: string): void {
367
- const keyName = getKeyName(key);
368
-
369
- if (keyName === 'return' || keyName === 'enter') {
370
- finalize(state.line);
371
- process.stdout.write('\n');
372
- // CRITICAL: Remove listener so promptAcceptReject doesn't get double-handled
373
- process.stdin.removeListener('data', state.boundOnData!);
374
- state.boundOnData = null;
375
- process.stdin.setRawMode(false);
376
- process.stdin.pause();
377
- resolve(state.line);
378
- return;
379
- }
380
-
381
- if (keyName === 'ctrl+c') {
382
- finalize(state.line);
383
- process.stdout.write('\n');
384
- process.stdin.removeListener('data', state.boundOnData!);
385
- state.boundOnData = null;
386
- process.stdin.setRawMode(false);
387
- process.stdin.pause();
388
- process.exit(0);
389
- return;
390
- }
391
-
392
- if (keyName === 'up') {
393
- if (state.history.length > 0 && state.histIdx < state.history.length - 1) {
394
- state.histIdx++;
395
- state.line = state.history[state.histIdx] ?? '';
396
- state.cursor = state.line.length;
397
- restoreCursorPosition();
398
- process.stdout.write(' '.repeat(200) + '\r' + inputPrompt + state.line);
399
- }
400
- return;
401
- }
402
-
403
- if (keyName === 'down') {
404
- if (state.histIdx > 0) {
405
- state.histIdx--;
406
- state.line = state.history[state.histIdx] ?? '';
407
- } else {
408
- state.histIdx = -1;
409
- state.line = '';
410
- }
411
- state.cursor = state.line.length;
412
- restoreCursorPosition();
413
- process.stdout.write(' '.repeat(200) + '\r' + inputPrompt + state.line);
414
- return;
415
- }
416
-
417
- if (keyName === 'backspace') {
418
- if (state.cursor > 0) {
419
- state.line = state.line.slice(0, state.cursor - 1) + state.line.slice(state.cursor);
420
- state.cursor = Math.max(0, state.cursor - 1);
421
- restoreCursorPosition();
422
- process.stdout.write(' '.repeat(200) + '\r' + inputPrompt + state.line);
423
- }
424
- return;
425
- }
426
-
427
- if (keyName === 'left') {
428
- if (state.cursor > 0) {
429
- state.cursor--;
430
- restoreCursorPosition();
431
- process.stdout.write('\r' + inputPrompt + state.line);
432
- }
433
- return;
434
- }
435
-
436
- if (keyName === 'right') {
437
- if (state.cursor < state.line.length) {
438
- state.cursor++;
439
- restoreCursorPosition();
440
- process.stdout.write('\r' + inputPrompt + state.line);
441
- }
442
- return;
443
- }
444
-
445
- if (key.length === 1 && key.charCodeAt(0) >= 0x20) {
446
- state.line = state.line.slice(0, state.cursor) + key + state.line.slice(state.cursor);
447
- state.cursor++;
448
- restoreCursorPosition();
449
- process.stdout.write(' '.repeat(200) + '\r' + inputPrompt + state.line);
450
- }
451
- }
452
-
453
- state.boundOnData = (key: string) => onData(key);
454
- process.stdin.on('data', state.boundOnData);
455
- });
456
- },
457
-
458
- close(): void { if (closeFn) closeFn(); },
459
-
460
- showPrompt(): void {
461
- moveToFreshLine();
462
- process.stdout.write(c.magenta('> '));
463
- saveCursorPosition();
464
- },
363
+ const startedAt = Date.now();
364
+ let frame = 0;
365
+ const label = clipText(message, Math.max(10, terminalWidth() - 14));
366
+ const render = (): void => {
367
+ const secs = Math.floor((Date.now() - startedAt) / 1000);
368
+ out.write('\r\x1b[2K' + c.magenta(FRAMES[frame++ % FRAMES.length] ?? '⠋') + ' ' + c.dim(`${label}… ${secs}s`));
465
369
  };
466
- }
467
-
468
- // =============================================================================
469
- // SPINNER
470
- // =============================================================================
471
-
472
- let spinnerInterval: ReturnType<typeof setInterval> | null = null;
473
370
 
474
- export function startSpinner(message: string): void {
475
- spinnerInterval = setInterval(() => {
476
- process.stdout.write('\r' + ' ' + c.dim(message) + '... ');
477
- }, 80);
371
+ out.write('\x1b[?25l');
372
+ render();
373
+ spinnerTimer = setInterval(render, 80);
478
374
  }
479
375
 
480
- export function stopSpinner(success?: boolean): void {
481
- if (spinnerInterval) {
482
- clearInterval(spinnerInterval);
483
- spinnerInterval = null;
484
- }
485
- if (success === true) {
486
- process.stdout.write('\r' + c.green('done.') + '\n');
487
- } else if (success === false) {
488
- process.stdout.write('\r' + c.red('failed.') + '\n');
489
- }
376
+ /** Stops the spinner and clears its line. The argument is kept for older call sites and is ignored. */
377
+ export function stopSpinner(_success?: boolean): void {
378
+ if (!spinnerTimer) return;
379
+ clearInterval(spinnerTimer);
380
+ spinnerTimer = null;
381
+ out.write('\r\x1b[2K\x1b[?25h');
490
382
  }
491
383
 
492
384
  export function clearScreen(): void {
493
- process.stdout.write('\x1b[2J\x1b[H');
494
- }
495
-
496
- // =============================================================================
497
- // KEY NAME PARSER
498
- // =============================================================================
499
-
500
- export function getKeyName(ch: string): string {
501
- if (ch === '\r') return 'return';
502
- if (ch === '\n') return 'enter';
503
- if (ch === '\x03') return 'ctrl+c';
504
- if (ch === '\x1b') return 'escape';
505
- if (ch === '\x7f' || ch === '\b') return 'backspace';
506
- if (ch === '\x04') return 'ctrl+d';
507
- if (ch === '\t') return 'tab';
508
- if (ch === '\x1b[A') return 'up';
509
- if (ch === '\x1b[B') return 'down';
510
- if (ch === '\x1b[C') return 'right';
511
- if (ch === '\x1b[D') return 'left';
512
- if (ch === '\x1b[3~') return 'delete';
513
- if (ch === '\x1b[F') return 'end';
514
- if (ch === '\x1b[H') return 'home';
515
- if (ch === '\x1b[5~') return 'pageup';
516
- if (ch === '\x1b[6~') return 'pagedown';
517
- if (ch.startsWith('\x1b[')) return ch;
518
- return ch.length === 1 ? ch : ch;
385
+ resetScreen();
519
386
  }