@bakapiano/ccsm 0.19.2 → 0.19.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/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@bakapiano/ccsm",
|
|
3
|
-
"version": "0.19.
|
|
3
|
+
"version": "0.19.3",
|
|
4
4
|
"description": "Claude Code Session Manager — Windows web UI to manage many concurrent claude sessions: live list, snapshot/restore, focus existing window, new session in an isolated workspace with repo clones",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"main": "server.js",
|
package/public/css/terminals.css
CHANGED
|
@@ -494,3 +494,93 @@
|
|
|
494
494
|
font-size: 11.5px !important;
|
|
495
495
|
color: rgba(232, 227, 213, 0.45) !important;
|
|
496
496
|
}
|
|
497
|
+
|
|
498
|
+
/* ─── Mobile terminal accessory bar (TerminalKeyBar.js) ───────────────
|
|
499
|
+
Floats just above the soft keyboard via a JS-set `bottom` offset
|
|
500
|
+
(visualViewport keyboard height). Styled against the dark terminal
|
|
501
|
+
palette — it visually belongs to the terminal, not the cream chrome —
|
|
502
|
+
so it reads as one surface with the xterm canvas above it. */
|
|
503
|
+
.term-keybar {
|
|
504
|
+
position: fixed;
|
|
505
|
+
left: 0;
|
|
506
|
+
right: 0;
|
|
507
|
+
z-index: 215; /* above the mobile FAB (210) */
|
|
508
|
+
background: #201d19;
|
|
509
|
+
border-top: 1px solid rgba(232, 227, 213, 0.12);
|
|
510
|
+
padding: 6px 8px;
|
|
511
|
+
touch-action: manipulation; /* kill the 300ms double-tap-zoom delay */
|
|
512
|
+
user-select: none;
|
|
513
|
+
-webkit-user-select: none;
|
|
514
|
+
/* NOT overflow:auto here — that would clip the Ctrl popover (which sits
|
|
515
|
+
at bottom:100%, above the bar). The horizontal scroll lives on the
|
|
516
|
+
inner .term-keybar-row instead. */
|
|
517
|
+
}
|
|
518
|
+
/* Inner scroll row — holds the keys; scrolls horizontally if they don't
|
|
519
|
+
fit, without clipping the popover that escapes the bar upward. */
|
|
520
|
+
.term-keybar-row {
|
|
521
|
+
display: flex;
|
|
522
|
+
gap: 6px;
|
|
523
|
+
align-items: center;
|
|
524
|
+
overflow-x: auto;
|
|
525
|
+
-webkit-overflow-scrolling: touch;
|
|
526
|
+
white-space: nowrap;
|
|
527
|
+
}
|
|
528
|
+
.term-keybar-row::-webkit-scrollbar { display: none; }
|
|
529
|
+
|
|
530
|
+
.tkb-key {
|
|
531
|
+
flex: 0 0 auto;
|
|
532
|
+
min-width: 42px;
|
|
533
|
+
height: 38px;
|
|
534
|
+
display: inline-flex;
|
|
535
|
+
align-items: center;
|
|
536
|
+
justify-content: center;
|
|
537
|
+
padding: 0 12px;
|
|
538
|
+
font-family: var(--mono);
|
|
539
|
+
font-size: 13px;
|
|
540
|
+
line-height: 1;
|
|
541
|
+
color: #e8e3d5;
|
|
542
|
+
background: rgba(232, 227, 213, 0.06);
|
|
543
|
+
border: 1px solid rgba(232, 227, 213, 0.14);
|
|
544
|
+
border-radius: 8px;
|
|
545
|
+
touch-action: manipulation;
|
|
546
|
+
-webkit-tap-highlight-color: transparent;
|
|
547
|
+
}
|
|
548
|
+
.tkb-key:active,
|
|
549
|
+
.tkb-key.is-active {
|
|
550
|
+
background: rgba(232, 227, 213, 0.20);
|
|
551
|
+
border-color: rgba(232, 227, 213, 0.32);
|
|
552
|
+
}
|
|
553
|
+
.tkb-arrow { padding: 0 10px; }
|
|
554
|
+
.tkb-arrow svg { width: 18px; height: 18px; }
|
|
555
|
+
/* S-Tab carries a multi-char label — let it size to content. */
|
|
556
|
+
.tkb-wide { padding: 0 12px; }
|
|
557
|
+
/* The ↵ glyph renders a touch small in the mono stack; bump it so it
|
|
558
|
+
matches the arrow icons' optical weight. */
|
|
559
|
+
.tkb-glyph { font-size: 17px; line-height: 1; }
|
|
560
|
+
|
|
561
|
+
/* Ctrl combos — a wrap grid that pops ABOVE the bar (bottom:100%). */
|
|
562
|
+
.term-keybar-pop {
|
|
563
|
+
position: absolute;
|
|
564
|
+
bottom: 100%;
|
|
565
|
+
left: 8px;
|
|
566
|
+
right: 8px;
|
|
567
|
+
z-index: 1; /* above the key row inside the bar's context */
|
|
568
|
+
margin-bottom: 6px;
|
|
569
|
+
display: grid;
|
|
570
|
+
grid-template-columns: repeat(5, 1fr);
|
|
571
|
+
gap: 6px;
|
|
572
|
+
padding: 8px;
|
|
573
|
+
background: #201d19;
|
|
574
|
+
border: 1px solid rgba(232, 227, 213, 0.16);
|
|
575
|
+
border-radius: 10px;
|
|
576
|
+
box-shadow: 0 -8px 24px -8px rgba(0, 0, 0, 0.5);
|
|
577
|
+
}
|
|
578
|
+
.tkb-combo {
|
|
579
|
+
flex-direction: column;
|
|
580
|
+
height: auto;
|
|
581
|
+
min-width: 0;
|
|
582
|
+
padding: 7px 4px;
|
|
583
|
+
gap: 2px;
|
|
584
|
+
}
|
|
585
|
+
.tkb-combo-label { font-family: var(--mono); font-size: 13px; color: #e8e3d5; }
|
|
586
|
+
.tkb-combo-hint { font-size: 9.5px; color: rgba(232, 227, 213, 0.5); letter-spacing: 0.01em; }
|
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
// Mobile-only terminal accessory bar. The soft keyboard has no Esc / Tab /
|
|
2
|
+
// arrows / Ctrl, which are exactly the keys claude & codex TUIs lean on
|
|
3
|
+
// (menu nav, cancel, autocomplete, interrupt). We float a row of those
|
|
4
|
+
// keys just above the soft keyboard — the same "extra-keys row" pattern
|
|
5
|
+
// Termux / Blink / Termius all settled on.
|
|
6
|
+
//
|
|
7
|
+
// Web has no native keyboard-accessory API, so the bar is a position:fixed
|
|
8
|
+
// element anchored to the top of the keyboard via the visualViewport API:
|
|
9
|
+
// when the keyboard opens, visualViewport.height shrinks and the gap below
|
|
10
|
+
// it (window.innerHeight − vv.height) is the keyboard's height; we park the
|
|
11
|
+
// bar at that offset.
|
|
12
|
+
//
|
|
13
|
+
// Every button MUST preventDefault on pointerdown — otherwise tapping it
|
|
14
|
+
// blurs the terminal's hidden textarea, which dismisses the soft keyboard
|
|
15
|
+
// (and would hide this bar). preventDefault keeps focus on the textarea so
|
|
16
|
+
// the keyboard stays up and we just inject the escape sequence over the WS.
|
|
17
|
+
|
|
18
|
+
import { html } from '../html.js';
|
|
19
|
+
import { useEffect, useRef, useState } from 'preact/hooks';
|
|
20
|
+
import { isMobile } from '../state.js';
|
|
21
|
+
import { IconChevronUp, IconChevronDown, IconChevronLeft, IconChevronRight } from '../icons.js';
|
|
22
|
+
|
|
23
|
+
// Ctrl+<letter> is the letter's code & 0x1f. Pre-computed for the combos
|
|
24
|
+
// that actually come up in a REPL / TUI session.
|
|
25
|
+
const CTRL_COMBOS = [
|
|
26
|
+
{ label: '^C', data: '\x03', hint: 'interrupt' },
|
|
27
|
+
{ label: '^D', data: '\x04', hint: 'EOF' },
|
|
28
|
+
{ label: '^Z', data: '\x1a', hint: 'suspend' },
|
|
29
|
+
{ label: '^R', data: '\x12', hint: 'rev-search' },
|
|
30
|
+
{ label: '^L', data: '\x0c', hint: 'clear' },
|
|
31
|
+
{ label: '^A', data: '\x01', hint: 'line start' },
|
|
32
|
+
{ label: '^E', data: '\x05', hint: 'line end' },
|
|
33
|
+
{ label: '^U', data: '\x15', hint: 'kill line' },
|
|
34
|
+
{ label: '^K', data: '\x0b', hint: 'kill to end' },
|
|
35
|
+
{ label: '^W', data: '\x17', hint: 'kill word' },
|
|
36
|
+
];
|
|
37
|
+
|
|
38
|
+
export function TerminalKeyBar({ send, cliType }) {
|
|
39
|
+
if (!isMobile.value) return null;
|
|
40
|
+
const [visible, setVisible] = useState(false);
|
|
41
|
+
const [kbOffset, setKbOffset] = useState(0);
|
|
42
|
+
const [ctrlOpen, setCtrlOpen] = useState(false);
|
|
43
|
+
const gesture = useRef({ x: 0, y: 0, id: null, moved: false });
|
|
44
|
+
|
|
45
|
+
// Show only while the terminal textarea holds focus (i.e. keyboard up).
|
|
46
|
+
// Buttons preventDefault so they never steal focus → no spurious blur
|
|
47
|
+
// while the bar is in use; focusout only fires on a genuine dismissal.
|
|
48
|
+
useEffect(() => {
|
|
49
|
+
const inTerm = (el) => !!(el && el.closest && el.closest('.terminal-host'));
|
|
50
|
+
const onFocusIn = (e) => { if (inTerm(e.target)) setVisible(true); };
|
|
51
|
+
const onFocusOut = () => {
|
|
52
|
+
// Defer one tick so document.activeElement settles to the new target.
|
|
53
|
+
setTimeout(() => {
|
|
54
|
+
if (!inTerm(document.activeElement)) { setVisible(false); setCtrlOpen(false); }
|
|
55
|
+
}, 0);
|
|
56
|
+
};
|
|
57
|
+
document.addEventListener('focusin', onFocusIn);
|
|
58
|
+
document.addEventListener('focusout', onFocusOut);
|
|
59
|
+
return () => {
|
|
60
|
+
document.removeEventListener('focusin', onFocusIn);
|
|
61
|
+
document.removeEventListener('focusout', onFocusOut);
|
|
62
|
+
};
|
|
63
|
+
}, []);
|
|
64
|
+
|
|
65
|
+
// Track the keyboard's top edge. window.innerHeight stays constant when
|
|
66
|
+
// the soft keyboard opens (both iOS Safari & Android Chrome with the
|
|
67
|
+
// default resizes-visual behaviour); vv.height shrinks. The difference
|
|
68
|
+
// is the keyboard height → the bar's distance from the layout-viewport
|
|
69
|
+
// bottom.
|
|
70
|
+
useEffect(() => {
|
|
71
|
+
const vv = window.visualViewport;
|
|
72
|
+
if (!vv) return;
|
|
73
|
+
const sync = () => setKbOffset(Math.max(0, window.innerHeight - vv.height - vv.offsetTop));
|
|
74
|
+
sync();
|
|
75
|
+
vv.addEventListener('resize', sync);
|
|
76
|
+
vv.addEventListener('scroll', sync);
|
|
77
|
+
return () => { vv.removeEventListener('resize', sync); vv.removeEventListener('scroll', sync); };
|
|
78
|
+
}, []);
|
|
79
|
+
|
|
80
|
+
if (!visible) return null;
|
|
81
|
+
|
|
82
|
+
// Insert-newline (composer multi-line). Mirrors TerminalView's Shift/Ctrl
|
|
83
|
+
// +Enter handler: claude's prompt parses a bare LF as insert-newline;
|
|
84
|
+
// crossterm-based TUIs (codex/copilot) take ESC+CR i.e. Alt+Enter. This
|
|
85
|
+
// is the ONLY way to add a newline on a soft keyboard whose Enter submits.
|
|
86
|
+
const newlineData = cliType === 'claude' ? '\n' : '\x1b\r';
|
|
87
|
+
|
|
88
|
+
// Tap-vs-drag discrimination. The key row scrolls horizontally
|
|
89
|
+
// (overflow-x), so a swipe to scroll it starts on a button — firing the
|
|
90
|
+
// key on pointerdown meant every scroll-drag injected a keystroke. Track
|
|
91
|
+
// the pointer from down→up and only fire if it stayed put (a real tap).
|
|
92
|
+
// One pointer at a time on a touch bar, so a single shared ref is enough.
|
|
93
|
+
//
|
|
94
|
+
// preventDefault on pointerdown keeps the terminal's textarea focused (the
|
|
95
|
+
// button never grabs focus) so the soft keyboard stays up. Scrolling is
|
|
96
|
+
// governed by touch-action, not this preventDefault, so the row still pans.
|
|
97
|
+
const DRAG_PX = 8;
|
|
98
|
+
const onDown = (e) => {
|
|
99
|
+
gesture.current = { x: e.clientX, y: e.clientY, id: e.pointerId, moved: false };
|
|
100
|
+
e.preventDefault();
|
|
101
|
+
};
|
|
102
|
+
const onMove = (e) => {
|
|
103
|
+
const g = gesture.current;
|
|
104
|
+
if (g.id !== e.pointerId || g.moved) return;
|
|
105
|
+
if (Math.hypot(e.clientX - g.x, e.clientY - g.y) > DRAG_PX) g.moved = true;
|
|
106
|
+
};
|
|
107
|
+
const onCancel = () => { gesture.current.moved = true; };
|
|
108
|
+
// Fire on release, but only for a tap (no drag) and the same pointer.
|
|
109
|
+
const keyProps = (fn) => ({
|
|
110
|
+
onPointerDown: onDown,
|
|
111
|
+
onPointerMove: onMove,
|
|
112
|
+
onPointerCancel: onCancel,
|
|
113
|
+
onPointerUp: (e) => {
|
|
114
|
+
const g = gesture.current;
|
|
115
|
+
if (g.id !== e.pointerId || g.moved) return;
|
|
116
|
+
e.preventDefault();
|
|
117
|
+
fn();
|
|
118
|
+
},
|
|
119
|
+
});
|
|
120
|
+
const sendKey = (data) => keyProps(() => send(data));
|
|
121
|
+
const ctrlCombo = (data) => keyProps(() => { send(data); setCtrlOpen(false); });
|
|
122
|
+
|
|
123
|
+
return html`
|
|
124
|
+
<div class="term-keybar" style=${`bottom:${kbOffset}px`}>
|
|
125
|
+
${ctrlOpen ? html`
|
|
126
|
+
<div class="term-keybar-pop">
|
|
127
|
+
${CTRL_COMBOS.map((c) => html`
|
|
128
|
+
<button class="tkb-key tkb-combo" key=${c.label}
|
|
129
|
+
...${ctrlCombo(c.data)} title=${c.hint}>
|
|
130
|
+
<span class="tkb-combo-label">${c.label}</span>
|
|
131
|
+
<span class="tkb-combo-hint">${c.hint}</span>
|
|
132
|
+
</button>`)}
|
|
133
|
+
</div>` : null}
|
|
134
|
+
|
|
135
|
+
<div class="term-keybar-row">
|
|
136
|
+
<button class=${`tkb-key${ctrlOpen ? ' is-active' : ''}`}
|
|
137
|
+
...${keyProps(() => setCtrlOpen((v) => !v))}>Ctrl</button>
|
|
138
|
+
<button class="tkb-key" ...${sendKey('\x1b')}>Esc</button>
|
|
139
|
+
<button class="tkb-key" ...${sendKey('\t')}>Tab</button>
|
|
140
|
+
<button class="tkb-key tkb-wide" ...${sendKey('\x1b[Z')}>S-Tab</button>
|
|
141
|
+
<button class="tkb-key tkb-arrow" ...${sendKey(newlineData)} aria-label="newline"><span class="tkb-glyph">↵</span></button>
|
|
142
|
+
<button class="tkb-key tkb-arrow" ...${sendKey('\x1b[A')} aria-label="up"><${IconChevronUp} /></button>
|
|
143
|
+
<button class="tkb-key tkb-arrow" ...${sendKey('\x1b[B')} aria-label="down"><${IconChevronDown} /></button>
|
|
144
|
+
<button class="tkb-key tkb-arrow" ...${sendKey('\x1b[D')} aria-label="left"><${IconChevronLeft} /></button>
|
|
145
|
+
<button class="tkb-key tkb-arrow" ...${sendKey('\x1b[C')} aria-label="right"><${IconChevronRight} /></button>
|
|
146
|
+
</div>
|
|
147
|
+
</div>`;
|
|
148
|
+
}
|
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
// output frames into xterm. Disposes everything on unmount or id change.
|
|
4
4
|
|
|
5
5
|
import { html } from '../html.js';
|
|
6
|
+
import { Fragment } from 'preact';
|
|
6
7
|
import { useEffect, useRef, useState } from 'preact/hooks';
|
|
7
8
|
import { Terminal } from '@xterm/xterm';
|
|
8
9
|
import { FitAddon } from '@xterm/addon-fit';
|
|
@@ -10,6 +11,7 @@ import { WebLinksAddon } from '@xterm/addon-web-links';
|
|
|
10
11
|
import { ClipboardAddon } from '@xterm/addon-clipboard';
|
|
11
12
|
import { WebglAddon } from '@xterm/addon-webgl';
|
|
12
13
|
import { wsBase, getToken, getDeviceId } from '../backend.js';
|
|
14
|
+
import { TerminalKeyBar } from './TerminalKeyBar.js';
|
|
13
15
|
|
|
14
16
|
// Dark xterm theme. We give the terminal a near-black ink background to
|
|
15
17
|
// match what claude code's TUI assumes (it paints its own input box +
|
|
@@ -44,6 +46,13 @@ export function TerminalView({ terminalId, cliType }) {
|
|
|
44
46
|
const [displaced, setDisplaced] = useState(false);
|
|
45
47
|
const [reattachNonce, setReattach] = useState(0);
|
|
46
48
|
|
|
49
|
+
// Raw escape-sequence injector for the mobile key bar. Reads wsRef at
|
|
50
|
+
// call time so it stays valid across reattaches without re-binding.
|
|
51
|
+
const sendInput = (data) => {
|
|
52
|
+
const ws = wsRef.current;
|
|
53
|
+
if (ws && ws.readyState === 1) ws.send(JSON.stringify({ type: 'input', data }));
|
|
54
|
+
};
|
|
55
|
+
|
|
47
56
|
useEffect(() => {
|
|
48
57
|
if (!terminalId || !hostRef.current) return;
|
|
49
58
|
|
|
@@ -418,5 +427,9 @@ export function TerminalView({ terminalId, cliType }) {
|
|
|
418
427
|
</div>
|
|
419
428
|
</section>`;
|
|
420
429
|
}
|
|
421
|
-
return html
|
|
430
|
+
return html`
|
|
431
|
+
<${Fragment}>
|
|
432
|
+
<div key="host" ref=${hostRef} class="terminal-host"></div>
|
|
433
|
+
<${TerminalKeyBar} send=${sendInput} cliType=${cliType} />
|
|
434
|
+
</${Fragment}>`;
|
|
422
435
|
}
|
|
@@ -25,6 +25,22 @@ import { PageTitleBar } from '../components/PageTitleBar.js';
|
|
|
25
25
|
import { EntityFormModal } from '../components/EntityFormModal.js';
|
|
26
26
|
import { useDragSort } from '../components/useDragSort.js';
|
|
27
27
|
import { IconPlus, IconPencil, IconClose, IconTerminal, IconFolder, IconBranch, IconRefresh, IconChevronUp, IconChevronDown, IconForCliType, IconClaudeColor, IconCodexColor, IconCopilotColor } from '../icons.js';
|
|
28
|
+
import { parseArgs, formatArgs } from '../util.js';
|
|
29
|
+
|
|
30
|
+
// Tokenize the three free-form args fields into string[] before they hit
|
|
31
|
+
// the backend. Form values arrive as strings (text inputs) — backend
|
|
32
|
+
// stores arrays. parseArgs handles shell-style quoting so users can type
|
|
33
|
+
// `-Model "claude-opus-4-8"` or `-Path 'C:\some dir\bin'` and get sane
|
|
34
|
+
// argv splitting instead of a literal-quote token.
|
|
35
|
+
function tokenizeCliArgs(v) {
|
|
36
|
+
const tok = (x) => typeof x === 'string' ? parseArgs(x) : x;
|
|
37
|
+
return {
|
|
38
|
+
...v,
|
|
39
|
+
args: tok(v.args),
|
|
40
|
+
resumeIdArgs: tok(v.resumeIdArgs),
|
|
41
|
+
newSessionIdArgs: tok(v.newSessionIdArgs),
|
|
42
|
+
};
|
|
43
|
+
}
|
|
28
44
|
|
|
29
45
|
// Type → smart defaults. Choosing a type in the form auto-fills resumeArgs
|
|
30
46
|
// (and command if blank) so users don't need to remember the per-CLI flag.
|
|
@@ -72,8 +88,8 @@ function cliFieldsFor({ creating } = {}) {
|
|
|
72
88
|
},
|
|
73
89
|
{ key: 'name', label: 'Name', placeholder: 'My CLI', required: true },
|
|
74
90
|
{ key: 'command', label: 'Command', mono: true, placeholder: 'ccp / claude / ...', required: true },
|
|
75
|
-
{ key: 'args', label: 'Args
|
|
76
|
-
hint: 'Used on every launch.' },
|
|
91
|
+
{ key: 'args', label: 'Args', mono: true, placeholder: '',
|
|
92
|
+
hint: 'Used on every launch. Shell-style quoting: -Model "claude-opus-4-8" or -Path \'C:\\some dir\\bin\'.' },
|
|
77
93
|
{ key: 'newSessionIdArgs', label: 'New session id args', mono: true, placeholder: '--session-id <id>',
|
|
78
94
|
// Lock for known types — those args are an integration contract
|
|
79
95
|
// with the upstream CLI, not a user knob. Only Type=Other allows
|
|
@@ -194,7 +210,7 @@ export function ConfigurePage() {
|
|
|
194
210
|
id: c.id,
|
|
195
211
|
icon: html`<${Icon} />`,
|
|
196
212
|
primary: c.name,
|
|
197
|
-
secondary: html`<span class="mono">${c.command}${c.args?.length ? ' ' + c.args
|
|
213
|
+
secondary: html`<span class="mono">${c.command}${c.args?.length ? ' ' + formatArgs(c.args) : ''}</span>${c.shell && c.shell !== 'direct' ? html` · ${c.shell}` : null}`,
|
|
198
214
|
badges: tags,
|
|
199
215
|
undeletable: c.builtin,
|
|
200
216
|
raw: c,
|
|
@@ -291,7 +307,7 @@ export function ConfigurePage() {
|
|
|
291
307
|
onClose=${close} submitLabel="Create"
|
|
292
308
|
onTest=${(v) => testCli({ command: v.command, shell: v.shell, type: v.type })}
|
|
293
309
|
onSubmit=${async (v) => {
|
|
294
|
-
try { await createCli(v); setToast(`created CLI · ${v.name}`); }
|
|
310
|
+
try { await createCli(tokenizeCliArgs(v)); setToast(`created CLI · ${v.name}`); }
|
|
295
311
|
catch (e) { setToast(e.message, 'error'); throw e; }
|
|
296
312
|
}} />` : null}
|
|
297
313
|
|
|
@@ -300,21 +316,15 @@ export function ConfigurePage() {
|
|
|
300
316
|
readOnlyKeys=${edit.payload.builtin ? ['type'] : []}
|
|
301
317
|
initial=${{
|
|
302
318
|
...edit.payload,
|
|
303
|
-
args: (edit.payload.args
|
|
304
|
-
resumeIdArgs: (edit.payload.resumeIdArgs
|
|
305
|
-
newSessionIdArgs: (edit.payload.newSessionIdArgs
|
|
319
|
+
args: formatArgs(edit.payload.args),
|
|
320
|
+
resumeIdArgs: formatArgs(edit.payload.resumeIdArgs),
|
|
321
|
+
newSessionIdArgs: formatArgs(edit.payload.newSessionIdArgs),
|
|
306
322
|
}}
|
|
307
323
|
onClose=${close}
|
|
308
324
|
onTest=${(v) => testCli({ command: v.command, shell: v.shell, type: v.type })}
|
|
309
325
|
onSubmit=${async (v) => {
|
|
310
326
|
try {
|
|
311
|
-
|
|
312
|
-
...v,
|
|
313
|
-
args: typeof v.args === 'string' ? v.args.split(/\s+/).filter(Boolean) : v.args,
|
|
314
|
-
resumeIdArgs: typeof v.resumeIdArgs === 'string' ? v.resumeIdArgs.split(/\s+/).filter(Boolean) : v.resumeIdArgs,
|
|
315
|
-
newSessionIdArgs: typeof v.newSessionIdArgs === 'string' ? v.newSessionIdArgs.split(/\s+/).filter(Boolean) : v.newSessionIdArgs,
|
|
316
|
-
};
|
|
317
|
-
await updateCli(edit.payload.id, patch);
|
|
327
|
+
await updateCli(edit.payload.id, tokenizeCliArgs(v));
|
|
318
328
|
setToast('saved');
|
|
319
329
|
} catch (e) { setToast(e.message, 'error'); throw e; }
|
|
320
330
|
}} />` : null}
|
package/public/js/util.js
CHANGED
|
@@ -22,3 +22,47 @@ export function displayTitle(label, fallback) {
|
|
|
22
22
|
export function nowClock() {
|
|
23
23
|
return new Date().toLocaleTimeString(undefined, { hour12: false });
|
|
24
24
|
}
|
|
25
|
+
|
|
26
|
+
// Shell-style argv tokenizer / formatter used by the CLI editor's
|
|
27
|
+
// args / resumeIdArgs / newSessionIdArgs fields. Modeled on POSIX sh
|
|
28
|
+
// word splitting + bash quoting (the rules every dev already has in
|
|
29
|
+
// muscle memory) — not a full shell parser. Handles:
|
|
30
|
+
// bare token -Model → "-Model"
|
|
31
|
+
// double-quoted "a b c" → "a b c"
|
|
32
|
+
// \\ and \" are escapes inside ""; any
|
|
33
|
+
// other backslash is kept literal, so
|
|
34
|
+
// "C:\Users\foo" survives intact (bash
|
|
35
|
+
// rule, matters for Windows paths).
|
|
36
|
+
// single-quoted 'a b c' → "a b c" literal, no escapes
|
|
37
|
+
// mixed -Foo "x y" 'z' → ["-Foo","x y","z"]
|
|
38
|
+
// Anything malformed (unclosed quote, etc.) falls through to a bare
|
|
39
|
+
// best-effort match so the user can keep typing without the field
|
|
40
|
+
// nuking their input mid-type.
|
|
41
|
+
export function parseArgs(input) {
|
|
42
|
+
const s = String(input || '');
|
|
43
|
+
const out = [];
|
|
44
|
+
const re = /'([^']*)'|"((?:[^"\\]|\\.)*)"|(\S+)/g;
|
|
45
|
+
let m;
|
|
46
|
+
while ((m = re.exec(s)) !== null) {
|
|
47
|
+
if (m[1] !== undefined) out.push(m[1]);
|
|
48
|
+
else if (m[2] !== undefined) out.push(m[2].replace(/\\([\\"])/g, '$1'));
|
|
49
|
+
else out.push(m[3]);
|
|
50
|
+
}
|
|
51
|
+
return out;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
// Inverse of parseArgs — used when re-populating the textarea from a
|
|
55
|
+
// stored array. Bare-emit when the token has no shell-significant chars;
|
|
56
|
+
// otherwise double-quote with \" and \\ escapes. Round-trip is stable
|
|
57
|
+
// (parse(format(arr)) === arr) for any string array.
|
|
58
|
+
export function formatArgs(arr) {
|
|
59
|
+
if (!Array.isArray(arr)) return '';
|
|
60
|
+
return arr.map((a) => {
|
|
61
|
+
const s = String(a ?? '');
|
|
62
|
+
if (s === '') return '""';
|
|
63
|
+
if (/[\s"'\\`$]/.test(s)) {
|
|
64
|
+
return '"' + s.replace(/([\\"])/g, '\\$1') + '"';
|
|
65
|
+
}
|
|
66
|
+
return s;
|
|
67
|
+
}).join(' ');
|
|
68
|
+
}
|