Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
27 commits
Select commit Hold shift + click to select a range
ec3c62a
feat(auth): allow passive terminal observation with a read scope
juliusmarminge Sep 4, 2026
524cee5
fix(web): gate terminal creation in panel menus
juliusmarminge Sep 4, 2026
f9315b7
test(terminal): model observer exit signals explicitly
juliusmarminge Sep 4, 2026
e83e51a
style(terminal): format passive observation controls
juliusmarminge Sep 4, 2026
c558712
fix(auth): include terminal observation in read-only grants
juliusmarminge Sep 4, 2026
2e082bd
fix(terminal): honor grants in script controls and resize replay
juliusmarminge Sep 4, 2026
cae715f
fix(mobile): simplify terminal input bounds guard
juliusmarminge Sep 4, 2026
e5e15da
fix(terminal): recheck grants after close confirmations
juliusmarminge Sep 4, 2026
e28b7c2
fix(terminal): keep local dismissal independent of access
juliusmarminge Sep 4, 2026
c25f899
fix(mobile): observe explicitly selected terminal sessions
juliusmarminge Sep 4, 2026
5e6a05a
fix(web): consume unavailable terminal action shortcuts
juliusmarminge Sep 4, 2026
a9a6c90
fix(mobile): respect terminal grants in chat actions
juliusmarminge Sep 4, 2026
b02a074
fix(server): surface terminal subscription lookup errors
juliusmarminge Sep 5, 2026
1685cd1
test(server): retain task scope in integrated RPC tests
juliusmarminge Sep 5, 2026
7d137b1
fix(web): preserve focus for terminal observers
juliusmarminge Sep 5, 2026
999b99f
fix(mobile): replay terminal size after access changes
juliusmarminge Sep 5, 2026
0c050bf
fix(terminal): preserve sessions across scope changes
juliusmarminge Sep 5, 2026
44cab63
fix(terminal): retain output during permission changes
juliusmarminge Sep 5, 2026
c8057bb
fix(terminal): gate actions on current scopes
juliusmarminge Sep 5, 2026
a3366a9
test(terminal): cover scoped path links on both platforms
juliusmarminge Sep 5, 2026
b3de27c
fix(mobile): keep pending terminal observers passive
juliusmarminge Sep 5, 2026
c768442
fix(terminal): remove duplicate test import after rebase
juliusmarminge Sep 5, 2026
6a348f7
fix(web): respect terminal access during agent setup
juliusmarminge Sep 5, 2026
8347006
fix(terminal): stop resizing after permission revocation
juliusmarminge Sep 5, 2026
12f7771
fix(web): leave terminal shortcuts alone without operate access
juliusmarminge Sep 6, 2026
f7f6e04
test(web): expect the vendor installer in the setup terminal
juliusmarminge Sep 7, 2026
b362e51
fix(auth): adapt terminal setup tests after onboarding rebase
juliusmarminge Sep 7, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,10 @@ class T3TerminalModule : Module() {
view.focusRequest = focusRequest
}

Prop("readOnly") { view: T3TerminalView, readOnly: Boolean ->
view.readOnly = readOnly
}

Prop("autoFocus") { view: T3TerminalView, autoFocus: Boolean ->
view.autoFocus = autoFocus
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -76,6 +76,16 @@ class T3TerminalView(context: Context, appContext: AppContext) : ExpoView(contex
}
}

var readOnly: Boolean = false
set(value) {
field = value
inputView.isEnabled = !value
if (value) {
inputView.clearFocus()
hideKeyboard()
}
}

var autoFocus: Boolean = true
set(value) {
field = value
Expand Down Expand Up @@ -214,6 +224,7 @@ class T3TerminalView(context: Context, appContext: AppContext) : ExpoView(contex
InputType.TYPE_TEXT_FLAG_NO_SUGGESTIONS
inputView.setPadding(0, 0, 0, 0)
inputView.setOnEditorActionListener { _, actionId, event ->
if (readOnly) return@setOnEditorActionListener true
val isKeyUp = event?.action == KeyEvent.ACTION_UP
val isImeSend = actionId == EditorInfo.IME_ACTION_SEND && !isKeyUp
val isHardwareEnter = event?.keyCode == KeyEvent.KEYCODE_ENTER &&
Expand All @@ -228,6 +239,7 @@ class T3TerminalView(context: Context, appContext: AppContext) : ExpoView(contex
}
}
inputView.setOnKeyListener { _, keyCode, event ->
if (readOnly) return@setOnKeyListener true
if (event.action != KeyEvent.ACTION_DOWN) return@setOnKeyListener false
when {
keyCode == KeyEvent.KEYCODE_DEL -> {
Expand All @@ -249,11 +261,11 @@ class T3TerminalView(context: Context, appContext: AppContext) : ExpoView(contex
override fun beforeTextChanged(s: CharSequence?, start: Int, count: Int, after: Int) = Unit

override fun onTextChanged(s: CharSequence?, start: Int, before: Int, count: Int) {
if (readOnly) return
if (clearingInput || s == null || count <= 0) return
val end = (start + count).coerceAtMost(s.length)
if (start >= end) return
val insertedText = s.subSequence(start, end).toString()
if (insertedText.isNotEmpty()) {
if (start < end) {
val insertedText = s.subSequence(start, end).toString()
onInput(mapOf("data" to insertedText))
}
}
Expand Down Expand Up @@ -366,12 +378,13 @@ class T3TerminalView(context: Context, appContext: AppContext) : ExpoView(contex
}

private fun emitResponse(response: ByteArray) {
if (response.isNotEmpty()) {
if (!readOnly && response.isNotEmpty()) {
onInput(mapOf("data" to String(response, Charsets.UTF_8)))
}
}

private fun requestKeyboardFocus() {
if (readOnly) return
inputView.requestFocus()
val inputMethodManager = context.getSystemService(
Context.INPUT_METHOD_SERVICE
Expand Down
4 changes: 4 additions & 0 deletions apps/mobile/modules/t3-terminal/ios/T3TerminalModule.swift
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,10 @@ public class T3TerminalModule: Module {
view.focusRequest = focusRequest
}

Prop("readOnly") { (view: T3TerminalView, readOnly: Bool) in
view.readOnly = readOnly
}

Prop("autoFocus") { (view: T3TerminalView, autoFocus: Bool) in
view.autoFocus = autoFocus
}
Expand Down
14 changes: 11 additions & 3 deletions apps/mobile/modules/t3-terminal/ios/T3TerminalView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -248,6 +248,14 @@ public final class T3TerminalView: ExpoView, UITextFieldDelegate {
}
}

var readOnly = false {
didSet {
if readOnly {
inputField.resignFirstResponder()
}
}
}

var autoFocus = true {
didSet {
guard oldValue != autoFocus else { return }
Expand Down Expand Up @@ -582,7 +590,7 @@ public final class T3TerminalView: ExpoView, UITextFieldDelegate {
guard let input = String(data: bytes, encoding: .utf8), !input.isEmpty else { return }

DispatchQueue.main.async {
view.onInput(["data": input])
view.emitInput(input)
}
}, userdata)
}
Expand Down Expand Up @@ -657,13 +665,13 @@ public final class T3TerminalView: ExpoView, UITextFieldDelegate {
}

private func requestKeyboardFocus() {
guard window != nil else { return }
guard window != nil, !readOnly else { return }
inputField.becomeFirstResponder()
textInputModeDidChange()
}

private func emitInput(_ data: String) {
guard !data.isEmpty else { return }
guard !readOnly, !data.isEmpty else { return }
onInput(["data": data])
}

Expand Down
44 changes: 30 additions & 14 deletions apps/mobile/src/features/terminal/NativeTerminalSurface.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ import {
type TerminalTheme,
} from "./terminalTheme";
import { terminalDebugLog } from "./terminalDebugLog";
import { useTerminalSurfaceBuffer } from "./useTerminalSurfaceBuffer";

interface TerminalInputEvent {
readonly data: string;
Expand All @@ -34,16 +35,21 @@ interface TerminalResizeEvent {

interface TerminalSurfaceProps extends ViewProps {
readonly terminalKey: string;
readonly buffer: string;
readonly buffer: string | null;
readonly fontSize?: number;
readonly isRunning: boolean;
readonly readOnly?: boolean;
readonly autoFocus?: boolean;
readonly keyboardFocusRequest?: number;
readonly theme?: TerminalTheme;
readonly onInput: (data: string) => void;
readonly onResize: (size: { readonly cols: number; readonly rows: number }) => void;
}

type ReadyTerminalSurfaceProps = Omit<TerminalSurfaceProps, "buffer"> & {
readonly buffer: string;
};

function estimateGridSize(input: {
readonly width: number;
readonly height: number;
Expand All @@ -57,29 +63,33 @@ function estimateGridSize(input: {
};
}

const FallbackTerminalSurface = memo(function FallbackTerminalSurface(props: TerminalSurfaceProps) {
const FallbackTerminalSurface = memo(function FallbackTerminalSurface(
props: ReadyTerminalSurfaceProps,
) {
const fontSize = props.fontSize ?? MOBILE_TYPOGRAPHY.label.fontSize;
const inputRef = useRef<TextInput>(null);
const { themeAppearance, themeId } = useAppearancePreferences();
const theme = props.theme ?? getMobileTerminalTheme(themeId, themeAppearance);
const statusLabel = props.isRunning
? "Native terminal unavailable. Using text fallback."
: "Open terminal to start a shell.";
const statusLabel = props.readOnly
? "Viewing terminal output."
: props.isRunning
? "Native terminal unavailable. Using text fallback."
: "Open terminal to start a shell.";

const handleLayout = (event: LayoutChangeEvent) => {
const { width, height } = event.nativeEvent.layout;
props.onResize(estimateGridSize({ width, height, fontSize }));
};

useEffect(() => {
if ((props.keyboardFocusRequest ?? 0) > 0) {
if (!props.readOnly && (props.keyboardFocusRequest ?? 0) > 0) {
inputRef.current?.blur();
const focusFrame = requestAnimationFrame(() => inputRef.current?.focus());
return () => cancelAnimationFrame(focusFrame);
}

return undefined;
}, [props.keyboardFocusRequest]);
}, [props.keyboardFocusRequest, props.readOnly]);

return (
<View
Expand Down Expand Up @@ -132,7 +142,7 @@ const FallbackTerminalSurface = memo(function FallbackTerminalSurface(props: Ter
autoCapitalize="none"
autoCorrect={false}
blurOnSubmit={false}
editable={props.isRunning}
editable={props.isRunning && !props.readOnly}
placeholder="type and press return"
placeholderTextColor={theme.mutedForeground}
returnKeyType="send"
Expand All @@ -152,9 +162,9 @@ const FallbackTerminalSurface = memo(function FallbackTerminalSurface(props: Ter
}}
/>
<Pressable
disabled={!props.isRunning}
disabled={!props.isRunning || props.readOnly}
style={({ pressed }) => ({
opacity: !props.isRunning ? 0.35 : pressed ? 0.65 : 1,
opacity: !props.isRunning || props.readOnly ? 0.35 : pressed ? 0.65 : 1,
paddingHorizontal: 10,
paddingVertical: 6,
borderRadius: 8,
Expand All @@ -172,6 +182,11 @@ const FallbackTerminalSurface = memo(function FallbackTerminalSurface(props: Ter
});

export const TerminalSurface = memo(function TerminalSurface(props: TerminalSurfaceProps) {
const buffer = useTerminalSurfaceBuffer(props);
return <ReadyTerminalSurface {...props} buffer={buffer} />;
});

const ReadyTerminalSurface = memo(function ReadyTerminalSurface(props: ReadyTerminalSurfaceProps) {
const fontSize = props.fontSize ?? MOBILE_TYPOGRAPHY.label.fontSize;
const { themeAppearance, themeId } = useAppearancePreferences();
const theme = props.theme ?? getMobileTerminalTheme(themeId, themeAppearance);
Expand All @@ -191,15 +206,15 @@ export const TerminalSurface = memo(function TerminalSurface(props: TerminalSurf
}, [hasNativeSurface, props.buffer.length, props.isRunning, props.terminalKey]);
const handleNativeInput = useCallback(
(event: NativeSyntheticEvent<TerminalInputEvent>) => {
if (!props.isRunning) {
if (!props.isRunning || props.readOnly) {
return;
}
terminalDebugLog("native:onInput", {
codes: Array.from(event.nativeEvent.data, (char) => char.codePointAt(0)),
});
onInput(event.nativeEvent.data);
},
[onInput, props.isRunning],
[onInput, props.isRunning, props.readOnly],
);
const handleNativeResize = useCallback(
(event: NativeSyntheticEvent<TerminalResizeEvent>) => {
Expand All @@ -216,9 +231,10 @@ export const TerminalSurface = memo(function TerminalSurface(props: TerminalSurf
<View style={props.style}>
<NativeTerminalSurfaceView
appearanceScheme={themeAppearance}
autoFocus={props.autoFocus ?? true}
autoFocus={!props.readOnly && (props.autoFocus ?? true)}
readOnly={props.readOnly ?? false}
backgroundColor={theme.background}
focusRequest={props.isRunning ? (props.keyboardFocusRequest ?? 0) : 0}
focusRequest={props.isRunning && !props.readOnly ? (props.keyboardFocusRequest ?? 0) : 0}
foregroundColor={theme.foreground}
mutedForegroundColor={theme.mutedForeground}
terminalKey={props.terminalKey}
Expand Down
Loading
Loading