@researchcomputer/pista 0.1.2 → 0.1.3

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.
package/dist/commands.js CHANGED
@@ -1,31 +1,100 @@
1
1
  import { randomUUID } from 'node:crypto';
2
- import { describeConfig, parseCompatApiStyle, parsePermissionMode, parseThinkingLevel, } from './config.js';
2
+ import { describeConfig, parseCompatApiStyle, parsePermissionMode, parseThinkingLevel } from './config.js';
3
3
  import { normalizeAgentName, normalizeEndpoint, errorMessage, saveApiKey, getApiKeyPath, getStoredApiKey, loadStoredPreferences, saveStoredPreferences, resolveConfiguredSkills, fetchGitHubSkill, fetchGitHubSkillIndex, } from './utils.js';
4
4
  const SLASH_COMMANDS = [
5
5
  { id: 'help', usage: '/help', description: 'Show this help message', allowWhileRunning: true, section: 'commands' },
6
6
  { id: 'init', usage: '/init', description: 'Analyze codebase and generate AGENTS.md', section: 'commands' },
7
7
  { id: 'name', usage: '/name [name]', description: 'Show or change agent name', section: 'commands' },
8
- { id: 'abort', usage: '/abort', description: 'Stop current agent execution', allowWhileRunning: true, section: 'commands' },
8
+ {
9
+ id: 'abort',
10
+ usage: '/abort',
11
+ description: 'Stop current agent execution',
12
+ allowWhileRunning: true,
13
+ section: 'commands',
14
+ },
9
15
  { id: 'clear', usage: '/clear', description: 'Clear the terminal transcript', section: 'commands' },
10
16
  { id: 'reset', usage: '/reset', description: 'Reset agent conversation state', section: 'commands' },
11
17
  { id: 'cost', usage: '/cost', description: 'Show current session token usage', section: 'commands' },
12
18
  { id: 'key', usage: '/key', description: 'Update your stored API key', section: 'commands' },
13
- { id: 'jump', usage: '/jump <target>', description: 'Jump transcript to latest | error | tool', insertText: '/jump ', section: 'commands' },
14
- { id: 'permissions', usage: '/permissions', description: 'Change tool permission mode', aliases: ['permission-mode'], section: 'commands' },
19
+ {
20
+ id: 'jump',
21
+ usage: '/jump <target>',
22
+ description: 'Jump transcript to latest | error | tool',
23
+ insertText: '/jump ',
24
+ section: 'commands',
25
+ },
26
+ {
27
+ id: 'permissions',
28
+ usage: '/permissions',
29
+ description: 'Change tool permission mode',
30
+ aliases: ['permission-mode'],
31
+ section: 'commands',
32
+ },
15
33
  { id: 'thinking', usage: '/thinking', description: 'Change reasoning intensity', section: 'commands' },
34
+ {
35
+ id: 'btw',
36
+ usage: '/btw [message]',
37
+ description: "Start a side conversation that won't affect history (Esc to exit)",
38
+ insertText: '/btw ',
39
+ section: 'commands',
40
+ },
16
41
  { id: 'quit', usage: '/quit', description: 'Exit the application', aliases: ['exit'], section: 'commands' },
17
42
  { id: 'plugins', usage: '/plugins', description: 'List installed plugins', aliases: ['skills'], section: 'plugins' },
18
- { id: 'plugins add', usage: '/plugins add', description: 'Add a new plugin (prompt or MCP)', insertText: '/plugins add ', section: 'plugins' },
19
- { id: 'plugins install', usage: '/plugins install', description: 'Install from GitHub (owner/repo/path)', insertText: '/plugins install ', aliases: ['plugins add-github', 'plugins github'], section: 'plugins' },
20
- { id: 'plugins browse', usage: '/plugins browse', description: 'Browse a GitHub skill repo', insertText: '/plugins browse ', section: 'plugins' },
21
- { id: 'plugins remove', usage: '/plugins remove', description: 'Remove a plugin', insertText: '/plugins remove ', aliases: ['plugins rm'], section: 'plugins' },
22
- { id: 'plugins enable', usage: '/plugins enable', description: 'Enable a disabled plugin', insertText: '/plugins enable ', section: 'plugins' },
23
- { id: 'plugins disable', usage: '/plugins disable', description: 'Disable a plugin', insertText: '/plugins disable ', section: 'plugins' },
43
+ {
44
+ id: 'plugins add',
45
+ usage: '/plugins add',
46
+ description: 'Add a new plugin (prompt or MCP)',
47
+ insertText: '/plugins add ',
48
+ section: 'plugins',
49
+ },
50
+ {
51
+ id: 'plugins install',
52
+ usage: '/plugins install',
53
+ description: 'Install from GitHub (owner/repo/path)',
54
+ insertText: '/plugins install ',
55
+ aliases: ['plugins add-github', 'plugins github'],
56
+ section: 'plugins',
57
+ },
58
+ {
59
+ id: 'plugins browse',
60
+ usage: '/plugins browse',
61
+ description: 'Browse a GitHub skill repo',
62
+ insertText: '/plugins browse ',
63
+ section: 'plugins',
64
+ },
65
+ {
66
+ id: 'plugins remove',
67
+ usage: '/plugins remove',
68
+ description: 'Remove a plugin',
69
+ insertText: '/plugins remove ',
70
+ aliases: ['plugins rm'],
71
+ section: 'plugins',
72
+ },
73
+ {
74
+ id: 'plugins enable',
75
+ usage: '/plugins enable',
76
+ description: 'Enable a disabled plugin',
77
+ insertText: '/plugins enable ',
78
+ section: 'plugins',
79
+ },
80
+ {
81
+ id: 'plugins disable',
82
+ usage: '/plugins disable',
83
+ description: 'Disable a plugin',
84
+ insertText: '/plugins disable ',
85
+ section: 'plugins',
86
+ },
24
87
  { id: 'session', usage: '/session [new]', description: 'Show session info or start a fresh one', section: 'session' },
25
88
  { id: 'config', usage: '/config', description: 'Show current configuration', section: 'session' },
26
89
  { id: 'model', usage: '/model [id]', description: 'Change model id', section: 'model' },
27
90
  { id: 'endpoint', usage: '/endpoint [url]', description: 'Change API endpoint URL', section: 'model' },
28
- { id: 'api', usage: '/api [style]', description: 'Set API style (chat or responses)', aliases: ['api-style'], section: 'model' },
91
+ {
92
+ id: 'api',
93
+ usage: '/api [style]',
94
+ description: 'Set API style (chat or responses)',
95
+ aliases: ['api-style'],
96
+ section: 'model',
97
+ },
29
98
  ];
30
99
  const SLASH_COMMAND_SECTION_TITLES = {
31
100
  commands: 'COMMANDS',
@@ -43,15 +112,10 @@ export function getSlashCommandSuggestions(input, running, limit = 6) {
43
112
  if (query === null)
44
113
  return [];
45
114
  const normalizedQuery = query.toLowerCase();
46
- const candidates = SLASH_COMMANDS
47
- .filter((item) => !running || item.allowWhileRunning)
115
+ const candidates = SLASH_COMMANDS.filter((item) => !running || item.allowWhileRunning)
48
116
  .map((item, index) => {
49
117
  const aliases = item.aliases ?? [];
50
- const searchTerms = [
51
- item.id,
52
- item.usage.replace(/^\//, ''),
53
- ...aliases,
54
- ].map((term) => term.toLowerCase());
118
+ const searchTerms = [item.id, item.usage.replace(/^\//, ''), ...aliases].map((term) => term.toLowerCase());
55
119
  let rank = normalizedQuery ? 3 : 1;
56
120
  if (normalizedQuery) {
57
121
  if (searchTerms.some((term) => term === normalizedQuery)) {
@@ -166,16 +230,17 @@ export async function handleSlashCommand(input, ctx) {
166
230
  }
167
231
  case 'permissions':
168
232
  case 'permission-mode': {
169
- const nextMode = args[0] ?? await ctx.openPicker({
170
- title: 'Permission mode',
171
- description: 'Choose how aggressively the agent may run tools.',
172
- items: [
173
- { id: 'default', label: 'default', description: 'Ask when the SDK needs approval.' },
174
- { id: 'allowAll', label: 'allowAll', description: 'Allow all tool calls without prompts.' },
175
- { id: 'rulesOnly', label: 'rulesOnly', description: 'Allow only rules-backed tool calls, deny the rest.' },
176
- ],
177
- selectedId: config.permissionMode,
178
- });
233
+ const nextMode = args[0] ??
234
+ (await ctx.openPicker({
235
+ title: 'Permission mode',
236
+ description: 'Choose how aggressively the agent may run tools.',
237
+ items: [
238
+ { id: 'default', label: 'default', description: 'Ask when the SDK needs approval.' },
239
+ { id: 'allowAll', label: 'allowAll', description: 'Allow all tool calls without prompts.' },
240
+ { id: 'rulesOnly', label: 'rulesOnly', description: 'Allow only rules-backed tool calls, deny the rest.' },
241
+ ],
242
+ selectedId: config.permissionMode,
243
+ }));
179
244
  if (!nextMode)
180
245
  return;
181
246
  const parsedMode = parsePermissionMode(nextMode);
@@ -187,19 +252,20 @@ export async function handleSlashCommand(input, ctx) {
187
252
  return;
188
253
  }
189
254
  case 'thinking': {
190
- const nextLevel = args[0] ?? await ctx.openPicker({
191
- title: 'Thinking level',
192
- description: 'Higher levels may improve answers, but they usually cost more and respond slower.',
193
- items: [
194
- { id: 'off', label: 'off', description: 'No extra reasoning budget.' },
195
- { id: 'minimal', label: 'minimal', description: 'Small reasoning budget.' },
196
- { id: 'low', label: 'low', description: 'Low reasoning budget.' },
197
- { id: 'medium', label: 'medium', description: 'Balanced reasoning budget.' },
198
- { id: 'high', label: 'high', description: 'More deliberate reasoning.' },
199
- { id: 'xhigh', label: 'xhigh', description: 'Maximum reasoning budget.' },
200
- ],
201
- selectedId: config.thinkingLevel,
202
- });
255
+ const nextLevel = args[0] ??
256
+ (await ctx.openPicker({
257
+ title: 'Thinking level',
258
+ description: 'Higher levels may improve answers, but they usually cost more and respond slower.',
259
+ items: [
260
+ { id: 'off', label: 'off', description: 'No extra reasoning budget.' },
261
+ { id: 'minimal', label: 'minimal', description: 'Small reasoning budget.' },
262
+ { id: 'low', label: 'low', description: 'Low reasoning budget.' },
263
+ { id: 'medium', label: 'medium', description: 'Balanced reasoning budget.' },
264
+ { id: 'high', label: 'high', description: 'More deliberate reasoning.' },
265
+ { id: 'xhigh', label: 'xhigh', description: 'Maximum reasoning budget.' },
266
+ ],
267
+ selectedId: config.thinkingLevel,
268
+ }));
203
269
  if (!nextLevel)
204
270
  return;
205
271
  const parsedLevel = parseThinkingLevel(nextLevel);
@@ -211,12 +277,13 @@ export async function handleSlashCommand(input, ctx) {
211
277
  return;
212
278
  }
213
279
  case 'name': {
214
- const nextName = rest || await ctx.openPrompt({
215
- title: 'Agent name',
216
- description: 'Pick a short and cute name for this coding agent.',
217
- initialValue: config.agentName,
218
- placeholder: 'Example: Pista',
219
- });
280
+ const nextName = rest ||
281
+ (await ctx.openPrompt({
282
+ title: 'Agent name',
283
+ description: 'Pick a short and cute name for this coding agent.',
284
+ initialValue: config.agentName,
285
+ placeholder: 'Example: Pista',
286
+ }));
220
287
  if (!nextName)
221
288
  return;
222
289
  const normalizedName = normalizeAgentName(nextName);
@@ -230,18 +297,19 @@ export async function handleSlashCommand(input, ctx) {
230
297
  try {
231
298
  const apiKey = getStoredApiKey();
232
299
  const response = await fetch(`${config.selection.endpoint}/models`, {
233
- headers: apiKey ? { 'Authorization': `Bearer ${apiKey}` } : {},
300
+ headers: apiKey ? { Authorization: `Bearer ${apiKey}` } : {},
234
301
  });
235
302
  if (response.ok) {
236
- const data = await response.json();
237
- const models = data.data.map(m => m.id);
303
+ const data = (await response.json());
304
+ const models = data.data.map((m) => m.id);
238
305
  if (models.length > 0) {
239
- nextModel = await ctx.openPicker({
240
- title: 'Select Model',
241
- items: models.map(id => ({ id, label: id })),
242
- selectedId: config.selection.model,
243
- emptyMessage: 'No models returned from endpoint.',
244
- }) || '';
306
+ nextModel =
307
+ (await ctx.openPicker({
308
+ title: 'Select Model',
309
+ items: models.map((id) => ({ id, label: id })),
310
+ selectedId: config.selection.model,
311
+ emptyMessage: 'No models returned from endpoint.',
312
+ })) || '';
245
313
  }
246
314
  }
247
315
  }
@@ -269,11 +337,12 @@ export async function handleSlashCommand(input, ctx) {
269
337
  return;
270
338
  }
271
339
  case 'endpoint': {
272
- const nextEndpoint = rest || await ctx.openPrompt({
273
- title: 'API endpoint',
274
- description: 'Example: https://api.research.computer/v1',
275
- initialValue: config.selection.endpoint,
276
- });
340
+ const nextEndpoint = rest ||
341
+ (await ctx.openPrompt({
342
+ title: 'API endpoint',
343
+ description: 'Example: https://api.research.computer/v1',
344
+ initialValue: config.selection.endpoint,
345
+ }));
277
346
  if (!nextEndpoint)
278
347
  return;
279
348
  try {
@@ -291,14 +360,15 @@ export async function handleSlashCommand(input, ctx) {
291
360
  }
292
361
  case 'api':
293
362
  case 'api-style': {
294
- const nextStyle = args[0] ?? await ctx.openPicker({
295
- title: 'Pick API style',
296
- items: [
297
- { id: 'chat', label: 'chat', description: 'OpenAI-compatible chat/completions API' },
298
- { id: 'responses', label: 'responses', description: 'OpenAI-compatible responses API' },
299
- ],
300
- selectedId: config.selection.apiStyle,
301
- });
363
+ const nextStyle = args[0] ??
364
+ (await ctx.openPicker({
365
+ title: 'Pick API style',
366
+ items: [
367
+ { id: 'chat', label: 'chat', description: 'OpenAI-compatible chat/completions API' },
368
+ { id: 'responses', label: 'responses', description: 'OpenAI-compatible responses API' },
369
+ ],
370
+ selectedId: config.selection.apiStyle,
371
+ }));
302
372
  if (!nextStyle)
303
373
  return;
304
374
  const parsedStyle = parseCompatApiStyle(nextStyle);
@@ -379,11 +449,12 @@ export async function handleSlashCommand(input, ctx) {
379
449
  return;
380
450
  }
381
451
  if (subcommand === 'add') {
382
- const id = rest.slice(4).trim() || await ctx.openPrompt({
383
- title: 'Plugin ID',
384
- description: 'A unique identifier for this plugin (e.g. "typescript-style", "my-mcp-server").',
385
- placeholder: 'my-plugin',
386
- });
452
+ const id = rest.slice(4).trim() ||
453
+ (await ctx.openPrompt({
454
+ title: 'Plugin ID',
455
+ description: 'A unique identifier for this plugin (e.g. "typescript-style", "my-mcp-server").',
456
+ placeholder: 'my-plugin',
457
+ }));
387
458
  if (!id)
388
459
  return;
389
460
  const description = await ctx.openPrompt({
@@ -411,7 +482,10 @@ export async function handleSlashCommand(input, ctx) {
411
482
  if (description)
412
483
  skill.description = description;
413
484
  if (promptText) {
414
- skill.promptSections = promptText.split('\\n').map((s) => s.trim()).filter(Boolean);
485
+ skill.promptSections = promptText
486
+ .split('\\n')
487
+ .map((s) => s.trim())
488
+ .filter(Boolean);
415
489
  }
416
490
  if (mcpChoice !== 'no') {
417
491
  const transport = mcpChoice;
@@ -428,12 +502,14 @@ export async function handleSlashCommand(input, ctx) {
428
502
  description: 'Space-separated arguments for the command.',
429
503
  placeholder: '-y @modelcontextprotocol/server-filesystem /',
430
504
  });
431
- skill.mcpServers = [{
505
+ skill.mcpServers = [
506
+ {
432
507
  name: id.trim(),
433
508
  transport,
434
509
  command,
435
510
  args: argsStr ? argsStr.split(/\s+/) : [],
436
- }];
511
+ },
512
+ ];
437
513
  }
438
514
  else {
439
515
  const url = await ctx.openPrompt({
@@ -464,11 +540,12 @@ export async function handleSlashCommand(input, ctx) {
464
540
  return;
465
541
  }
466
542
  if (subcommand === 'add-github' || subcommand === 'github' || subcommand === 'install') {
467
- const source = rest.slice(subcommand.length + 1).trim() || await ctx.openPrompt({
468
- title: 'GitHub source',
469
- description: 'Enter owner/repo or owner/repo/path/to/skill (e.g. "anthropics/skills/skills/pdf").',
470
- placeholder: 'anthropics/skills/skills/pdf',
471
- });
543
+ const source = rest.slice(subcommand.length + 1).trim() ||
544
+ (await ctx.openPrompt({
545
+ title: 'GitHub source',
546
+ description: 'Enter owner/repo or owner/repo/path/to/skill (e.g. "anthropics/skills/skills/pdf").',
547
+ placeholder: 'anthropics/skills/skills/pdf',
548
+ }));
472
549
  if (!source)
473
550
  return;
474
551
  ctx.appendLog('system', 'Fetching plugin', `Downloading SKILL.md from ${source}...`);
@@ -503,11 +580,12 @@ export async function handleSlashCommand(input, ctx) {
503
580
  return;
504
581
  }
505
582
  if (subcommand === 'browse') {
506
- const source = rest.slice(7).trim() || await ctx.openPrompt({
507
- title: 'GitHub repository',
508
- description: 'Enter owner/repo to browse available skills (e.g. "anthropics/skills").',
509
- placeholder: 'anthropics/skills',
510
- });
583
+ const source = rest.slice(7).trim() ||
584
+ (await ctx.openPrompt({
585
+ title: 'GitHub repository',
586
+ description: 'Enter owner/repo to browse available skills (e.g. "anthropics/skills").',
587
+ placeholder: 'anthropics/skills',
588
+ }));
511
589
  if (!source)
512
590
  return;
513
591
  const parts = source.replace(/https?:\/\/github\.com\//, '').split('/');
@@ -582,14 +660,15 @@ export async function handleSlashCommand(input, ctx) {
582
660
  ctx.appendLog('system', 'Plugins', 'No plugins to remove.');
583
661
  return;
584
662
  }
585
- const targetId = args[1] || await ctx.openPicker({
586
- title: 'Remove plugin',
587
- items: allSkills.map((s) => ({
588
- id: `${s.scope}:${s.id}`,
589
- label: `${s.id} [${s.scope}]`,
590
- description: s.description,
591
- })),
592
- });
663
+ const targetId = args[1] ||
664
+ (await ctx.openPicker({
665
+ title: 'Remove plugin',
666
+ items: allSkills.map((s) => ({
667
+ id: `${s.scope}:${s.id}`,
668
+ label: `${s.id} [${s.scope}]`,
669
+ description: s.description,
670
+ })),
671
+ }));
593
672
  if (!targetId)
594
673
  return;
595
674
  const [targetScope, ...idParts] = targetId.split(':');
@@ -615,19 +694,20 @@ export async function handleSlashCommand(input, ctx) {
615
694
  ...(globalPrefs.skills ?? []).map((s) => ({ ...s, scope: 'global' })),
616
695
  ...(projectPrefs.skills ?? []).map((s) => ({ ...s, scope: 'project' })),
617
696
  ];
618
- const filtered = allSkills.filter((s) => enabling ? s.enabled === false : s.enabled !== false);
697
+ const filtered = allSkills.filter((s) => (enabling ? s.enabled === false : s.enabled !== false));
619
698
  if (filtered.length === 0) {
620
699
  ctx.appendLog('system', 'Plugins', `No plugins to ${subcommand}.`);
621
700
  return;
622
701
  }
623
- const targetId = args[1] || await ctx.openPicker({
624
- title: `${enabling ? 'Enable' : 'Disable'} plugin`,
625
- items: filtered.map((s) => ({
626
- id: `${s.scope}:${s.id}`,
627
- label: `${s.id} [${s.scope}]`,
628
- description: s.description,
629
- })),
630
- });
702
+ const targetId = args[1] ||
703
+ (await ctx.openPicker({
704
+ title: `${enabling ? 'Enable' : 'Disable'} plugin`,
705
+ items: filtered.map((s) => ({
706
+ id: `${s.scope}:${s.id}`,
707
+ label: `${s.id} [${s.scope}]`,
708
+ description: s.description,
709
+ })),
710
+ }));
631
711
  if (!targetId)
632
712
  return;
633
713
  const [targetScope, ...idParts] = targetId.split(':');
@@ -648,6 +728,22 @@ export async function handleSlashCommand(input, ctx) {
648
728
  ctx.appendLog('error', 'Unknown subcommand', `Usage: /plugins [list|add|install|browse|remove|enable|disable]`);
649
729
  return;
650
730
  }
731
+ case 'btw': {
732
+ if (ctx.isBtwActive()) {
733
+ ctx.appendLog('system', 'Already in btw mode', 'Press Esc to exit first.');
734
+ return;
735
+ }
736
+ if (rest) {
737
+ const entered = ctx.enterBtw(true);
738
+ if (entered) {
739
+ await ctx.promptAgent(rest);
740
+ }
741
+ }
742
+ else {
743
+ ctx.enterBtw(false);
744
+ }
745
+ return;
746
+ }
651
747
  case 'quit':
652
748
  case 'exit':
653
749
  ctx.exit();
@@ -34,3 +34,40 @@ test('getSlashCommandSuggestions hides the popup once arguments are being typed'
34
34
  test('getSlashCommandSuggestions hides the popup after a completion adds a trailing space', () => {
35
35
  assert.deepEqual(getSlashCommandSuggestions('/plugins add ', false), []);
36
36
  });
37
+ test('parseSlashCommand handles multi-word command', () => {
38
+ const result = parseSlashCommand('/plugins add my-plugin');
39
+ assert.equal(result.command, 'plugins');
40
+ assert.deepEqual(result.args, ['add', 'my-plugin']);
41
+ assert.equal(result.rest, 'add my-plugin');
42
+ });
43
+ test('parseSlashCommand lowercases command', () => {
44
+ const result = parseSlashCommand('/HELP');
45
+ assert.equal(result.command, 'help');
46
+ });
47
+ test('getSlashCommandSuggestions returns empty for non-slash input', () => {
48
+ const suggestions = getSlashCommandSuggestions('hello', false);
49
+ assert.equal(suggestions.length, 0);
50
+ });
51
+ test('getSlashCommandSuggestions filters by running state', () => {
52
+ const idle = getSlashCommandSuggestions('/h', false);
53
+ const running = getSlashCommandSuggestions('/h', true);
54
+ assert.ok(idle.length >= running.length);
55
+ });
56
+ test('getSlashCommandSuggestions respects limit', () => {
57
+ const suggestions = getSlashCommandSuggestions('/', false, 2);
58
+ assert.ok(suggestions.length <= 2);
59
+ });
60
+ test('getSlashCommandSuggestions includes btw', () => {
61
+ const suggestions = getSlashCommandSuggestions('/btw', false);
62
+ assert.ok(suggestions.some((s) => s.id === 'btw'));
63
+ });
64
+ test('parseSlashCommand parses /btw with message', () => {
65
+ const result = parseSlashCommand('/btw what is this function?');
66
+ assert.equal(result.command, 'btw');
67
+ assert.equal(result.rest, 'what is this function?');
68
+ });
69
+ test('parseSlashCommand parses /btw without message', () => {
70
+ const result = parseSlashCommand('/btw');
71
+ assert.equal(result.command, 'btw');
72
+ assert.equal(result.rest, '');
73
+ });
@@ -2,8 +2,18 @@ import test from 'node:test';
2
2
  import assert from 'node:assert/strict';
3
3
  import { renderMarkdownLines } from './assistant-message-render.js';
4
4
  test('renderMarkdownLines flattens common markdown formatting for terminal output', () => {
5
- assert.deepEqual(renderMarkdownLines('# Title\n\nSee [docs](https://example.com) and **bold** text.'), ['Title', '', 'See docs (https://example.com) and bold text.']);
5
+ assert.deepEqual(renderMarkdownLines('# Title\n\nSee [docs](https://example.com) and **bold** text.'), [
6
+ 'Title',
7
+ '',
8
+ 'See docs (https://example.com) and bold text.',
9
+ ]);
6
10
  });
7
11
  test('renderMarkdownLines preserves code blocks while normalizing block syntax', () => {
8
- assert.deepEqual(renderMarkdownLines('> note\n- item\n1. step\n---\n```ts\nconst value = 1;\n```'), ['| note', '• item', '1. step', '────────', 'const value = 1;']);
12
+ assert.deepEqual(renderMarkdownLines('> note\n- item\n1. step\n---\n```ts\nconst value = 1;\n```'), [
13
+ '| note',
14
+ '• item',
15
+ '1. step',
16
+ '────────',
17
+ 'const value = 1;',
18
+ ]);
9
19
  });
@@ -5,5 +5,5 @@ import { renderMarkdownLines } from './assistant-message-render.js';
5
5
  export const AssistantMessage = React.memo(function AssistantMessage({ content, showSpinner = false, spinner, }) {
6
6
  const body = content.trimEnd();
7
7
  const lines = renderMarkdownLines(body);
8
- return (_jsxs(Box, { flexDirection: "column", children: [body ? lines.map((line, index) => (_jsx(Text, { children: line || ' ' }, index))) : _jsx(Text, { dimColor: true, children: "[no content]" }), showSpinner && spinner ? (_jsxs(Box, { marginTop: 1, children: [_jsx(Text, { children: " " }), spinner] })) : null] }));
8
+ return (_jsxs(Box, { flexDirection: "column", children: [body ? lines.map((line, index) => _jsx(Text, { children: line || ' ' }, index)) : _jsx(Text, { dimColor: true, children: "[no content]" }), showSpinner && spinner ? (_jsxs(Box, { marginTop: 1, children: [_jsx(Text, { children: " " }), spinner] })) : null] }));
9
9
  });
@@ -0,0 +1,11 @@
1
+ import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
2
+ import React from 'react';
3
+ import { Box, Text } from 'ink';
4
+ import { Frame } from './frame.js';
5
+ import { Editor } from './editor.js';
6
+ import { SlashCommandMenu } from './slash-command-menu.js';
7
+ export const ComposerPanel = React.memo(function ComposerPanel({ value, cursor, agentName, running, btwActive = false, slashMenuVisible, slashCommandItems, slashMenuSelectedIndex, termWidth, }) {
8
+ return (_jsxs(_Fragment, { children: [slashMenuVisible ? (_jsx(SlashCommandMenu, { items: slashCommandItems, selectedIndex: Math.min(slashMenuSelectedIndex, Math.max(0, slashCommandItems.length - 1)) })) : null, _jsx(Frame, { borderColor: btwActive ? 'magenta' : running ? 'yellow' : 'cyan', bottomLeft: slashMenuVisible
9
+ ? 'Enter send · ⇧Enter newline · Tab complete · /help'
10
+ : 'Enter send · ⇧Enter newline · /help', termWidth: termWidth, children: _jsxs(Box, { children: [_jsx(Text, { color: "greenBright", bold: true, children: '> ' }), _jsx(Editor, { value: value, cursor: cursor, placeholder: `Ask ${agentName} or type /help` })] }) })] }));
11
+ });
@@ -16,7 +16,9 @@ export function editBuffer(current, input, key) {
16
16
  const chars = Array.from(current.value);
17
17
  let cursor = Math.max(0, Math.min(current.cursor, chars.length));
18
18
  const isBackspaceInput = input === '\x7f' || input === '\b' || input === '\x08' || (key.ctrl && input === 'h');
19
- const shouldTreatDeleteAsBackspace = key.delete && !input && cursor === chars.length && cursor > 0;
19
+ // Many terminals report Backspace as key.delete with empty input.
20
+ // When key.delete fires with no input string, treat it as backspace.
21
+ const shouldTreatDeleteAsBackspace = key.delete && !input;
20
22
  const isBackspace = key.backspace || isBackspaceInput || shouldTreatDeleteAsBackspace;
21
23
  if (isBackspace) {
22
24
  if (cursor > 0) {
@@ -51,7 +53,7 @@ export function editBuffer(current, input, key) {
51
53
  }
52
54
  // Character insertion
53
55
  if (input && input.length > 0) {
54
- const inputChars = Array.from(input).filter(c => {
56
+ const inputChars = Array.from(input).filter((c) => {
55
57
  const code = c.charCodeAt(0);
56
58
  return code >= 32 && code !== 127;
57
59
  });
@@ -30,10 +30,18 @@ test('delete at end of line behaves like backspace for terminals that map it tha
30
30
  const result = editBuffer({ value: 'abc', cursor: 3 }, '', key({ delete: true }));
31
31
  assert.deepEqual(result, { value: 'ab', cursor: 2 });
32
32
  });
33
- test('delete inside the line still removes the character at the cursor', () => {
34
- const result = editBuffer({ value: 'abcd', cursor: 1 }, '', key({ delete: true }));
33
+ test('key.delete with no input in the middle of the line acts as backspace', () => {
34
+ const result = editBuffer({ value: 'abcd', cursor: 2 }, '', key({ delete: true }));
35
35
  assert.deepEqual(result, { value: 'acd', cursor: 1 });
36
36
  });
37
+ test('delete with no input inside the line acts as backspace', () => {
38
+ const result = editBuffer({ value: 'abcd', cursor: 1 }, '', key({ delete: true }));
39
+ assert.deepEqual(result, { value: 'bcd', cursor: 0 });
40
+ });
41
+ test('delete at cursor 0 with no input is a no-op', () => {
42
+ const result = editBuffer({ value: 'abcd', cursor: 0 }, '', key({ delete: true }));
43
+ assert.deepEqual(result, { value: 'abcd', cursor: 0 });
44
+ });
37
45
  test('ctrl+key combinations are ignored by the editor buffer', () => {
38
46
  const result = editBuffer({ value: 'abcd', cursor: 2 }, 'p', key({ ctrl: true }));
39
47
  assert.deepEqual(result, { value: 'abcd', cursor: 2 });
@@ -1,19 +1,18 @@
1
1
  import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
2
  import React from 'react';
3
3
  import { Box, Text } from 'ink';
4
- export const Frame = React.memo(function Frame({ children, borderColor, topLeft, topRight, bottomLeft, bottomRight, paddingX = 1, }) {
5
- const termWidth = process.stdout.columns || 80;
6
- const usableWidth = termWidth - 2; // account for outer padding in app
4
+ export const Frame = React.memo(function Frame({ children, borderColor, topLeft, topRight, bottomLeft, bottomRight, paddingX = 1, termWidth, }) {
5
+ const usableWidth = (termWidth ?? 80) - 2; // account for outer padding in app
7
6
  return (_jsxs(Box, { flexDirection: "column", width: "100%", children: [_jsx(TopBorder, { width: usableWidth, color: borderColor, left: topLeft, right: topRight }), _jsx(Box, { flexDirection: "column", paddingX: paddingX, children: children }), _jsx(BottomBorder, { width: usableWidth, color: borderColor, left: bottomLeft, right: bottomRight })] }));
8
7
  });
9
- function TopBorder({ width, color, left, right }) {
8
+ function TopBorder({ width, color, left, right, }) {
10
9
  const leftText = left ? ` ${left} ` : '';
11
10
  const rightText = right ? ` ${right} ` : '';
12
11
  const fillLen = Math.max(0, width - 3 - leftText.length - rightText.length);
13
12
  const fill = '─'.repeat(fillLen);
14
13
  return (_jsxs(Text, { color: color, children: ["\u256D\u2500", leftText, fill, rightText, "\u256E"] }));
15
14
  }
16
- function BottomBorder({ width, color, left, right }) {
15
+ function BottomBorder({ width, color, left, right, }) {
17
16
  const leftText = left ? ` ${left} ` : '';
18
17
  const rightText = right ? ` ${right} ` : '';
19
18
  const fillLen = Math.max(0, width - 3 - leftText.length - rightText.length);
@@ -47,9 +47,9 @@ export function formatPendingToolsLine(pendingTools, termWidth) {
47
47
  }
48
48
  return `▸ ${shown.join(', ')}`;
49
49
  }
50
- export const Header = React.memo(function Header({ agentName, sessionId, model, apiStyle, endpoint, permissionMode, thinkingLevel, status, pendingTools, }) {
50
+ export const Header = React.memo(function Header({ agentName, sessionId, model, apiStyle, endpoint, permissionMode, thinkingLevel, status, pendingTools, termWidth: termWidthProp, }) {
51
51
  const color = statusColor(status);
52
- const termWidth = process.stdout.columns || 80;
52
+ const termWidth = termWidthProp ?? (process.stdout.columns || 80);
53
53
  // Build config line with progressive truncation for narrow terminals
54
54
  const configParts = [model, apiStyle];
55
55
  if (termWidth >= 60 && permissionMode !== 'default')
@@ -59,5 +59,5 @@ export const Header = React.memo(function Header({ agentName, sessionId, model,
59
59
  const host = truncateStr(stripEndpoint(endpoint), Math.max(10, termWidth - 30));
60
60
  const toolsLine = formatPendingToolsLine(pendingTools, termWidth);
61
61
  const toolsActive = pendingTools.length > 0;
62
- return (_jsxs(Frame, { borderColor: "gray", children: [_jsxs(Box, { justifyContent: "space-between", children: [_jsxs(Text, { bold: true, color: "cyanBright", children: ["\u25CF ", truncateStr(agentName, Math.max(4, termWidth - status.length - 8))] }), _jsxs(Text, { color: color, children: ["\u25CF ", status] })] }), _jsx(Text, { dimColor: true, wrap: "truncate", children: configParts.join(' · ') }), _jsxs(Text, { dimColor: true, wrap: "truncate", children: [host, " session:", sessionId.slice(0, 8)] }), _jsx(Text, { color: toolsActive ? 'yellow' : undefined, dimColor: !toolsActive, wrap: "truncate", children: toolsLine })] }));
62
+ return (_jsxs(Frame, { borderColor: "gray", termWidth: termWidth, children: [_jsxs(Box, { justifyContent: "space-between", children: [_jsxs(Text, { bold: true, color: "cyanBright", children: ["\u25CF ", truncateStr(agentName, Math.max(4, termWidth - status.length - 8))] }), _jsxs(Text, { color: color, children: ["\u25CF ", status] })] }), _jsx(Text, { dimColor: true, wrap: "truncate", children: configParts.join(' · ') }), _jsxs(Text, { dimColor: true, wrap: "truncate", children: [host, " session:", sessionId.slice(0, 8)] }), _jsx(Text, { color: toolsActive ? 'yellow' : undefined, dimColor: !toolsActive, wrap: "truncate", children: toolsLine })] }));
63
63
  });
@@ -17,12 +17,12 @@ function formatTime(timestamp) {
17
17
  const s = String(d.getSeconds()).padStart(2, '0');
18
18
  return `${h}:${m}:${s}`;
19
19
  }
20
- export const LogRow = React.memo(function LogRow({ entry }) {
20
+ export const LogRow = React.memo(function LogRow({ entry, termWidth: termWidthProp, }) {
21
21
  const color = logColor(entry.kind);
22
22
  const symbol = SYMBOLS[entry.kind] ?? '·';
23
23
  const time = formatTime(entry.timestamp);
24
24
  const bodyLines = (entry.body || '[no content]').split('\n');
25
- const termWidth = process.stdout.columns || 80;
25
+ const termWidth = termWidthProp ?? (process.stdout.columns || 80);
26
26
  const showTime = termWidth >= 50;
27
27
  return (_jsxs(Box, { flexDirection: "column", marginBottom: 1, children: [_jsxs(Box, { justifyContent: "space-between", children: [_jsxs(Text, { color: color, bold: true, children: [symbol, " ", entry.title] }), showTime ? _jsx(Text, { dimColor: true, children: time }) : null] }), _jsx(Box, { paddingLeft: 2, flexDirection: "column", children: entry.kind === 'assistant' ? (_jsx(AssistantMessage, { content: entry.body || '[no content]' })) : (bodyLines.map((line, index) => (_jsx(Text, { wrap: "truncate", children: line || ' ' }, `${entry.id}-${index}`)))) })] }));
28
28
  });