Terminal newlines, OAuth callback, Claude Code settings, and the Files tab #30

Merged
jknapp merged 62 commits from ship/core into main 2026-08-25 18:02:58 +00:00
4 changed files with 74 additions and 7 deletions
Showing only changes of commit dd23a52b41 - Show all commits
+7 -3
View File
@@ -1216,10 +1216,14 @@ change. Remember that a headless run cannot answer a permission prompt, so in an
**Bypass** a task may stop early when Claude Code asks for approval; the run log records the mode **Bypass** a task may stop early when Claude Code asks for approval; the run log records the mode
that was used. that was used.
### Creating Tasks (In the Container) ### Creating Tasks
There is no "add task" form in the app. Create tasks from a terminal in the container — either type The quickest route is the **New task** button on a project's **Automation** tab, which gives you a
the commands yourself in a **Shell** session, or just ask Claude to do it. form for the name, the schedule and the prompt.
You can also create tasks from a terminal in the container — type the commands yourself in a
**Shell** session, or just ask Claude to do it. That is the better route when you want Claude to
work out the schedule or the prompt for you, and it is what the rest of this section covers.
### Create a Recurring Task ### Create a Recurring Task
+36 -2
View File
@@ -431,6 +431,18 @@
const mobileInput = document.getElementById('mobileInput'); const mobileInput = document.getElementById('mobileInput');
const btnEnter = document.getElementById('btnEnter'); const btnEnter = document.getElementById('btnEnter');
const btnNewline = document.getElementById('btnNewline'); const btnNewline = document.getElementById('btnNewline');
// Whether the *active* session understands ESC+CR as "insert a newline".
//
// Only Claude Code does. `bash -l` has no readline binding for `\e\r`, so
// sending it there is a silent no-op — which is worse from the mobile bar
// than from a hardware key, because the bar puts a dedicated button on
// screen that appears to do nothing. The xterm key handler is already scoped
// this way; these two paths were not.
function activeSessionTakesEscCr() {
const s = activeSessionId && sessions[activeSessionId];
return !!s && s.type === 'claude';
}
const btnTab = document.getElementById('btnTab'); const btnTab = document.getElementById('btnTab');
const btnCtrlC = document.getElementById('btnCtrlC'); const btnCtrlC = document.getElementById('btnCtrlC');
const scrollBottomBtn = document.getElementById('scrollBottomBtn'); const scrollBottomBtn = document.getElementById('scrollBottomBtn');
@@ -791,6 +803,7 @@
switchToSession(remaining[remaining.length - 1]); switchToSession(remaining[remaining.length - 1]);
} else { } else {
activeSessionId = null; activeSessionId = null;
syncNewlineButton();
emptyState.style.display = ''; emptyState.style.display = '';
} }
} }
@@ -817,6 +830,7 @@
function switchToSession(sessionId) { function switchToSession(sessionId) {
activeSessionId = sessionId; activeSessionId = sessionId;
syncNewlineButton();
// Update tab styles // Update tab styles
document.querySelectorAll('.tab').forEach(t => t.classList.remove('active')); document.querySelectorAll('.tab').forEach(t => t.classList.remove('active'));
@@ -896,7 +910,7 @@
// reasoning, as the terminal's own key handler above. A hardware // reasoning, as the terminal's own key handler above. A hardware
// keyboard on a tablet is the only way to reach this; the phone case is // keyboard on a tablet is the only way to reach this; the phone case is
// the dedicated newline button beside Enter. // the dedicated newline button beside Enter.
sendTerminalInput(e.shiftKey ? '\x1b\r' : '\r'); sendTerminalInput(e.shiftKey && activeSessionTakesEscCr() ? '\x1b\r' : '\r');
} else if (e.key === 'Tab') { } else if (e.key === 'Tab') {
e.preventDefault(); e.preventDefault();
sendTerminalInput('\t'); sendTerminalInput('\t');
@@ -904,7 +918,27 @@
}); });
btnEnter.onclick = () => { sendTerminalInput('\r'); mobileInput.focus(); }; btnEnter.onclick = () => { sendTerminalInput('\r'); mobileInput.focus(); };
btnNewline.onclick = () => { sendTerminalInput('\x1b\r'); mobileInput.focus(); }; btnNewline.onclick = () => {
if (!activeSessionTakesEscCr()) { mobileInput.focus(); return; }
sendTerminalInput('\x1b\r');
mobileInput.focus();
};
// Keep the button's affordance honest: on a shell tab there is no byte that
// means "newline without running the line", so the control is disabled
// rather than left looking live.
function syncNewlineButton() {
const usable = activeSessionTakesEscCr();
btnNewline.disabled = !usable;
btnNewline.title = usable
? 'Insert a newline without submitting (Shift+Enter)'
: 'Only Claude sessions support this — a shell runs the line instead';
}
// With no session open yet, `activeSessionTakesEscCr()` is already false —
// but nothing had called this, so the button rendered live before the first
// tab existed.
syncNewlineButton();
btnTab.onclick = () => { sendTerminalInput('\t'); mobileInput.focus(); }; btnTab.onclick = () => { sendTerminalInput('\t'); mobileInput.focus(); };
btnCtrlC.onclick = () => { sendTerminalInput('\x03'); mobileInput.focus(); }; btnCtrlC.onclick = () => { sendTerminalInput('\x03'); mobileInput.focus(); };
@@ -109,7 +109,14 @@ export default function RuntimeSection({
<ConfigGroup <ConfigGroup
title="Claude Code settings" title="Claude Code settings"
description="Per-project CLI behaviour. Anything left on Global follows Settings; Off overrides a global On." description={
"Per-project CLI behaviour. Anything left on Global follows Settings; " +
"Off overrides a global On. Turning TUI mode, Effort level or Focus mode " +
"back to Global needs the container's base image updated first — those " +
"three are cleared by removing a key, and an older image's startup script " +
"ignores the instruction to remove it. Update the base image from Overview " +
"if one of them will not switch off."
}
> >
<ClaudeCodeSettingsEditor <ClaudeCodeSettingsEditor
scope="project" scope="project"
+23 -1
View File
@@ -491,9 +491,31 @@ if [ -f "$CLAUDE_JSON" ]; then
# Only rewrite when the value isn't already true, to avoid a needless jq # Only rewrite when the value isn't already true, to avoid a needless jq
# reformat of ~/.claude.json on every single start. # reformat of ~/.claude.json on every single start.
if ! grep -q '"shiftEnterKeyBindingInstalled"[[:space:]]*:[[:space:]]*true' "$CLAUDE_JSON" 2>/dev/null; then if ! grep -q '"shiftEnterKeyBindingInstalled"[[:space:]]*:[[:space:]]*true' "$CLAUDE_JSON" 2>/dev/null; then
# Write to a temp file and rename, never `> "$CLAUDE_JSON"`.
#
# `>` truncates before it writes, so a write that fails part-way — a
# full home volume is the obvious way, and bounding that volume is
# what half this release is about — leaves the file unparseable. This
# file holds the OAuth account, and the damage does not self-heal: the
# next start's `jq` fails on the corrupt file, `MERGED` is empty, and
# the guard below skips the write that would have repaired it. So the
# failure mode is a permanently lost login, for a purely cosmetic flag
# that suppresses a "run /terminal-setup" tip.
#
# `triple-c-task-runner` already does it this way; this block was
# modelled on the awsAuthRefresh one above, which has the same flaw but
# only fires when a Bedrock SSO command is configured.
MERGED=$(jq '.shiftEnterKeyBindingInstalled = true' "$CLAUDE_JSON" 2>/dev/null) MERGED=$(jq '.shiftEnterKeyBindingInstalled = true' "$CLAUDE_JSON" 2>/dev/null)
if [ -n "$MERGED" ]; then if [ -n "$MERGED" ]; then
printf '%s\n' "$MERGED" > "$CLAUDE_JSON" CLAUDE_JSON_TMP="${CLAUDE_JSON}.triple-c-tmp"
if printf '%s\n' "$MERGED" > "$CLAUDE_JSON_TMP" 2>/dev/null; then
mv -f "$CLAUDE_JSON_TMP" "$CLAUDE_JSON" 2>/dev/null || rm -f "$CLAUDE_JSON_TMP"
else
# Out of space, or the volume went read-only. The original is
# untouched, which is the whole point.
rm -f "$CLAUDE_JSON_TMP"
echo "entrypoint: warning — could not set shiftEnterKeyBindingInstalled (leaving ~/.claude.json as it was)"
fi
fi fi
fi fi
else else