Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
24 commits
Select commit Hold shift + click to select a range
d22242e
feat(auth): allow passive terminal observation with a read scope
juliusmarminge Sep 4, 2026
dcf6a15
fix(web): gate terminal creation in panel menus
juliusmarminge Sep 4, 2026
eb1b9ce
test(terminal): model observer exit signals explicitly
juliusmarminge Sep 4, 2026
bcace14
style(terminal): format passive observation controls
juliusmarminge Sep 4, 2026
c182f99
fix(auth): include terminal observation in read-only grants
juliusmarminge Sep 4, 2026
63bd248
fix(terminal): honor grants in script controls and resize replay
juliusmarminge Sep 4, 2026
bed35dd
fix(mobile): simplify terminal input bounds guard
juliusmarminge Sep 4, 2026
4f4d98c
fix(terminal): recheck grants after close confirmations
juliusmarminge Sep 4, 2026
c078358
fix(terminal): keep local dismissal independent of access
juliusmarminge Sep 4, 2026
a502027
fix(mobile): observe explicitly selected terminal sessions
juliusmarminge Sep 4, 2026
55c5cae
fix(web): consume unavailable terminal action shortcuts
juliusmarminge Sep 4, 2026
486db5d
fix(mobile): respect terminal grants in chat actions
juliusmarminge Sep 4, 2026
beea839
fix(server): surface terminal subscription lookup errors
juliusmarminge Sep 5, 2026
0a249f5
test(server): retain task scope in integrated RPC tests
juliusmarminge Sep 5, 2026
155350a
fix(web): preserve focus for terminal observers
juliusmarminge Sep 5, 2026
e7d392d
fix(mobile): replay terminal size after access changes
juliusmarminge Sep 5, 2026
5bc75ed
fix(terminal): preserve sessions across scope changes
juliusmarminge Sep 5, 2026
35a6b7e
fix(terminal): retain output during permission changes
juliusmarminge Sep 5, 2026
eb79072
fix(terminal): gate actions on current scopes
juliusmarminge Sep 5, 2026
8f98e8d
test(terminal): cover scoped path links on both platforms
juliusmarminge Sep 5, 2026
4e6b196
fix(mobile): keep pending terminal observers passive
juliusmarminge Sep 5, 2026
f2c289e
fix(terminal): remove duplicate test import after rebase
juliusmarminge Sep 5, 2026
c61d9ba
fix(web): respect terminal access during agent setup
juliusmarminge Sep 5, 2026
e104363
fix(terminal): stop resizing after permission revocation
juliusmarminge Sep 5, 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